ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

给 Agent 的网站:API 契约与 Skills 设计如何让工具调用更可靠

给 Agent 的网站:API 契约与 Skills 设计如何让工具调用更可靠

做 Agent 开发这一年多,我最大的体会是:模型的智力水平已经不是主要瓶颈,工具链的稳定性才是。为了让 Agent 更可靠,我花了几周时间做了一个专门给 Agent 用的网站,核心是一组结构稳定、语义干净的 API,外加一套可热插拔的 skills 技能目录。模型再聪明,只要它调用的接口偶尔变一下格式,或者文档里某个参数没写清楚,整个任务链就会像多米诺骨牌一样塌掉。这篇文章把项目的设计思路、接口契约、skills 的组织方式、实测数据和踩坑记录完整过一遍,适合正在做 Agent 落地、想提高工具调用可靠性的开发者参考。

1. 为什么要给 Agent 单独建一个网站

1.1 Agent 不可靠,锅不能全让模型背

很多人一遇到 Agent 跑偏,第一反应就是换模型、调 temperature、堆提示词。但我在实际项目中碰到的问题,大部分根本不是模型"笨",而是工具侧太脏。

Agent 的可靠性是多个环节叠加出来的:模型负责理解任务、构造调用、解析结果,工具侧负责接收参数、执行操作、返回数据。只要其中一个环节语义不一致,整条链就崩。举个最常见的例子,你给我一个查询天气的接口,文档里写city: string,模型就会自由发挥——传成都、传成都市、传chengdu、传四川省成都市,四种写法它一天之内全给你试一遍。你一看日志觉得是模型不行,其实是你给的接口参数定义太随意,模型只是照着你给的模糊信息做了最差的猜测。

这就是我建这个网站的出发点:把 Agent 要用的所有能力,提前收拾成一套机器能准确理解的"工作环境"。类比一下就是,你请了个能力很强的实习生,可你给它的工作手册写得模棱两可、表单设计得乱七八糟、同一个按钮有时候按了有反应有时候没反应,那你不能怪实习生办事不力。先把工作环境打扫干净,可靠性自然就上来了。

1.2 网站形态比文档和死函数好在哪

一开始我也想过,既然要给 Agent 提供能力,直接丢一堆 markdown 文档,或者在代码里注册一堆函数不就行了?实践下来都不够。面向 Agent 的"网站"有几个独特优势:

  • 可审计。所有请求都会经过 HTTP 层,天然留下日志,哪个请求参数错了、哪一步重试了,事后都能翻出来查。
  • 可版本化。接口演进可以加版本号,旧调用方不会被突然破坏,这对长期运行的 Agent 任务非常重要。
  • 可观测。健康检查、指标、限流这些运维能力直接复用成熟的 Web 生态,不用自己从零造。
  • 跨语言。不管 Agent 用什么语言、什么框架写的,只要走 HTTP,统一都能消费。
  • 天然适合发现机制。给一个固定入口地址,Agent 就能顺着它找到全部能力清单,不用在代码里硬编码每一条。

这个网站长什么样?它有一个简洁的首页,首页上明确写着"本服务提供以下能力:技能清单、接口目录、调用规范、常见错误说明"。真正的技能详情不堆在 HTML 里,而是放在机器可读的 JSON 端点里,方便模型直接抓取解析。人眼看 HTML 摘要定位问题,模型读 JSON 拿准信息,各取所需。

1.3 谁最需要这套东西

影响范围很明确:凡是做"多步骤任务型 Agent"的人,都应该关注这个思路。单轮问答的聊天机器人用不上,因为它不需要调外部工具;但一旦 Agent 要完成"查天气 + 订提醒 + 生成日报"这种连续动作,工具可靠性的权重就急剧上升。我自己手上的几个项目,从日程管理到数据报表,全部受益于这套改造。后面我会给出实测数据,改造前后工具调用出错率从 23% 降到了 5% 以内。

2. 面向 Agent 的网站怎么搭:架构与接口契约

2.1 技术选型:为什么用轻量 Web 框架

技术栈我选得很克制,没有上微服务,也没有引入复杂中间件。后端用的是 FastAPI,一个 Python 的轻量异步 Web 框架;前端尽量用静态页面,避免大量 JS 渲染;存储先上 SQLite,等数据量真的大了再迁移;部署用单机 Docker Compose 搞定。

选 FastAPI 最核心的原因:它自动生成 OpenAPI schema,也就是/openapi.json这个端点。现在很多 Agent 框架和模型服务商都支持直接从 OpenAPI 里加载工具,你后端定好接口,前端模型就能"看到"每个接口的参数结构、类型、必填项、返回值。这比手写工具描述靠谱得多——工具描述是给模型看的合约,手写容易漏,自动生成至少和代码是一致的。

部署上,单机 Docker Compose 足够。一个服务进程,一个 SQLite 文件,一个 Nginx 反代,三个容器搞定。说实话这类工具型站点流量不大,重点是稳定和可复现,没必要一开始就上 Kubernetes。

2.2 统一响应结构与错误语义

给 Agent 用的接口,最忌讳的就是每个接口返回格式都不一样。有的返回数组,有的返回对象,有的把数据包在result里,有的包在data里,模型解析的时候很容易取错字段。我在这套网站里统一了所有接口的响应结构:

{ "code": 0, "message": "ok", "data": { "city": "成都", "temperature": 28 } }

code用数字不用字符串,因为模型对数字判等更稳;0 代表成功,非 0 代表失败。message必须写成人话,同时要具体,不能全返回"请求失败"这种空话。data是业务数据的统一容器。

错误语义同样要标准化。参数不合法就返回 400,资源不存在返回 404,字段冲突返回 409,触发限流返回 429,数据源临时不可用返回 503。每个错误响应里必须带上具体原因和期望格式。比如:

{ "code": 40001, "message": "参数 units 取值不合法,仅支持 metric 或 imperial", "data": null }

这样模型在下一次调用前就知道该怎么修正。如果错误信息里只写"参数错误",模型就只能瞎猜,猜来猜去就开始幻觉了。

2.3 幂等设计:防止重试搞出脏数据

面向人的系统里,用户点两次提交按钮,我们通常会去重;面向 Agent 的系统里,这个问题的严重程度翻倍,因为 Agent 的重试策略往往非常激进。任务一超时,它可能自动重试两三次,如果接口没有幂等保护,数据库里就会多出好几条重复记录。

我的做法是:所有有副作用的接口(创建、更新、删除类)强制要求带上Idempotency-Key请求头。第一次请求时后端生成结果并缓存这个 key 对应的响应,后续相同 key 的请求直接返回第一次的结果,不再执行真正的操作。

这个设计解决了两个问题:一是重试不会产生脏数据,二是模型收到的多份响应完全一致,不会因为第二次请求时间戳不同而困惑。实测中这个改动直接把"重试导致重复写入"这类问题清零。

3. Skills 设计,关键是不让模型误解

3.1 Skill 的目录结构与加载机制

"Skills"这个词在这套系统里指的不是传统意义上的技能文件,而是一个完整的、可独立装载的能力单元。每个 Skill 是一个目录,包含四个部分:SKILL.md 能力说明、接口定义文件、校验脚本、示例调用。

目录结构大致是这样的:

skills/ weather-query/ SKILL.md openapi.json validate.py examples.json reminder-manager/ SKILL.md openapi.json validate.py examples.json

为什么这么拆?因为 Agent 的上下文窗口是有限的,它不可能把所有技能细节一次性装进脑袋里。网站提供一个/skills/manifest端点,只返回技能清单摘要,比如技能名字、一句话描述、涉及几个接口。Agent 先浏览这个清单,判断当前任务需要哪些技能,再单独拉取对应 SKILL.md 的完整内容。这个机制很像进入工具箱先看标签,再取对应的那把扳手,而不是把整个工具箱倒进兜里。

3.2 SKILL.md 写作规范:写给大模型看的手册

这一节是整篇文章最想强调的部分。大模型读文档的方式和人完全不一样。人看文档会扫标题、抓重点、脑补上下文;模型是按字面意思逐段理解。所以 SKILL.md 的写作有一套完全不同的规则。

我总结下来最重要的四条经验:

  • 开头必须写清楚能力边界,直接说明"这个技能做什么、不做什么"。模型对否定句的理解不可靠,所以最好用正面表述:"仅支持查询中国县级以上城市的实时天气与未来三天预报。"
  • 描述操作统一用"输入→处理→输出"三段式。每定义一个接口,先说清楚传给它是用什么格式的输入,再说它会做什么,最后给一个明确的返回示例。
  • 每个参数必须给示例值。没有示例值的参数,模型百分之百会自由发挥。给一个"正确写法"模板,它就会照抄。
  • 容易混淆的概念用术语表集中列出来。比如"城市名仅写 '成都',不要带省或市后缀",单独放一个"常见错误"小节比散落在正文里管用得多。

给一个简化版示例,实际项目里我会把它写得更细:

--- name: weather-query description: 查询指定城市当前天气与未来三天预报 --- ## 能力范围 - 支持查询中国县级以上城市 - 返回实时温度、体感温度、相对湿度、风向风速 - 仅支持当前天气与未来三天预报 ## 接口 GET /api/weather/{city} 参数: - city: 城市名,示例值为 成都。只填城市名本身,不要带省或市后缀 - units: 可选,取值 metric 或 imperial,默认 metric ## 返回示例 {"code":0,"data":{"city":"成都","now":28,"unit":"C"}}

模型拿到这份文档,理解成本已经降到最低。实测下来,参数格式错误在这个环节大幅减少。

3.3 参数校验与兜底容错

文档写得再好,也不能假设模型每次都能听话。所以我给每个接口都加了双层防护。

第一层是入参校验,用 JSON Schema 定义每个参数的类型、范围、枚举值。校验不过就返回统一的 400 错误,并明确指出哪些字段不合法、期望是什么格式。

第二层是兜底容错。比如城市名查询,文档要求只写"成都",但模型偶尔还是会传"成都市"。我最初的选择是直接报错,后来发现报错会让模型陷入反复重试的循环。更好的方案是后端做一个归一化模糊匹配:去掉省市区后缀、做同音字和别名映射,实在匹配不到才返回明确的"不存在该城市"。

这里有个重要的取舍:兜底容错不能做得太"聪明"。如果后端悄悄把每个模糊请求都猜对,模型就永远学不会正确传参,而且某些业务场景下模糊匹配会静默返回错误数据。我的原则是:容错只解决格式层面的小偏差(加后缀、大小写、空格),不解决语义层面的重大歧义;一旦命中兜底,响应里会标记matched: true和实际命中的标准值,让模型的错误可以在日志中被发现。

4. 实测调优记录:从 23% 错误率降到 5% 以内

4.1 本地联调环境怎么搭

为了让这套系统真正接受检验,我搭了一个本地联调环境。服务用 Docker Compose 跑起来,然后用一个通用 Agent 框架接入,把网站的/openapi.json地址直接作为工具源。模型选用的是某国产大模型服务商的 API,任务设计成一个复合场景:让 Agent 列出未来三天会下雨的城市,给最早下雨的三个城市设置提醒,最后生成一段汇总文字。

整个流程分四步走:

  1. 启动网站服务,确认/healthz健康检查通过。
  2. Agent 框架拉取 OpenAPI schema,自动注册工具。
  3. 下发复合任务,让 Agent 自行规划调用顺序。
  4. 记录每一次工具调用的请求参数、返回结果、耗时和错误。

这套环境的好处是闭环很快,改动一行接口文档,重新跑一遍同样的任务就能对比出效果差异。这也是后面所有调优数据都来自同一套测试脚本的原因,保证对照是控制变量的。

4.2 改造前后对比数据

我跑了 100 个同样的复合任务,统计每 100 个任务中出现至少一次工具层错误的次数。改造前是直接在原生接口上让 Agent 调用,改造后是走这套带 skills 的网站。

错误类别改造前改造后
参数构造错误(格式/取值不对)101
实体名歧义(多写/少写后缀、别名)60
返回结构解析错误(取错字段)41
重试导致的副作用问题20
超时/限流误判12
合计234

23% 降到 4%,不是换了模型,也不是加了提示词,全部是工具侧改造的收益。其中超时/限流误判从 1 升到 2,是这轮测试中的小噪声,后面会在限流参数上再调优。整体趋势非常明确:把工具侧做干净,Agent 的可靠性立刻上了一个台阶。

4.3 三个典型失败案例修复实录

案例一:模型把城市名带上了省名后缀。日志里频繁出现city=四川省成都市,导致查询失败。修复方式是双管齐下:SKILL.md 参数说明中加了一行明确示例"成都,不要带省或市后缀";后端同时加了归一化匹配兜底。改完之后这类错误清零。

案例二:模型重复调用创建提醒接口。任务要求设置三个提醒,模型因为第一次调用响应略慢,自动重试了一次,差点生成四条重复提醒。修复方式就是前面说的幂等键。加了Idempotency-Key之后,重试返回的结果和第一次完全一致,重复数据问题彻底消失。

案例三:模型面对限流时疯狂重试。某段时间查询频率过高触发了 429,模型的重试策略又很激进,导致请求雪崩。修复方式是 429 响应里带上Retry-After头,并在错误信息中明确写"请等待 3 秒后重试,期间不要重复调用"。模型收到这种带明确指导的错误信息,表现得比收到空泛的"系统繁忙"要正常得多。这印证了一件事:模型不会用超能力,你给它的错误信息越具体,它的后续行为就越合理。

5. 常见问题与排查技巧实录

5.1 问题速查表

实操中积累了不少典型问题,整理成速查表,碰到了可以直接对号入座。

现象可能原因解决方案
Agent 反复传同一个错误参数参数说明缺少示例值在 SKILL.md 里给每个参数补一个正确写法示例
工具调用超时后任务中断接口慢 + Agent 重试策略激进接口层加缓存;错误响应给出明确的重试指引
OpenAPI schema 太大塞不进上下文技能过多一次性加载用 /skills/manifest 先返回摘要,按需拉取详情
模型解析 HTML 页面出错页面含大量导航、样式噪声提供 /agent 极简路径,只返回纯文本或 JSON
重复创建数据缺少幂等保护所有写接口强制要求 Idempotency-Key
模型把数字字段当字符串schema 类型定义不严严格配置 JSON Schema 类型,并给示例值

5.2 上下文膨胀:技能太多装不下怎么办

技能数量一多,新的问题就来了。我一度把十几个技能全部暴露出来,OpenAPI schema 拉下来要占几千个 token,还没开始干活,上下文就去了小半。模型在这种状态下更容易漏调用、选错工具。

解决办法是分层加载。manifest 清单只暴露最精简的信息:技能名、一句话描述、适用场景。比如"weather-query:查询城市实时天气和未来三天预报,适用于需要做天气判断的任务"。模型判断当前任务可能需要天气,再单独请求该技能的完整定义。这个方法很像点菜先看菜单上的菜名,而不是把后厨的完整配方都背下来。技能数量上了二十个之后,这个机制几乎是必须的。

5.3 日志观测:坑都是从这里找到的

所有接口都要打日志,而且要打完整:trace_id、时间戳、模型传来的原始参数、归一化后的实际参数、返回的完整响应、耗时。没有这套日志,很多问题根本无从排查。

我最深的体会是,Agent 开发排障,难点不是看报错,而是还原"模型当时是怎么理解你的接口的"。日志里记录的原始参数就是最好的证据。有次我以为是模型把单位写错了,翻日志才发现是文档里参数名units和示例值C让模型误以为单位是"Celsius 缩写",实际上我们要的是 metric 体系。这种问题看响应看不出名堂,但看日志一秒就定位了。

建议所有接口位置都加上一个统一的拦截器,自动在响应头写入X-Trace-Id,前端 Agent 也要求它透传这个 ID。这样从模型调用到后端处理,整条链路都能串起来。

6. 这套方案能覆盖哪些场景

6.1 适合复用的场景

从我的实际使用来看,这套模式最适配这几类场景:一是数据查询类工具,天气、股票、政策、知识库这类以读为主的接口,结构清晰,收益最大;二是企业管理类工具,比如日程提醒、工单创建、报表生成,它们严重依赖参数准确性和幂等保护,正好是这套系统的主场;三是多 Agent 协作系统,多个 Agent 共享同一套工具契约,网站成了统一的中枢,接口规范一致的收益会被成倍放大。

这套方案还有个额外的好处:新人接手项目时不用再靠口口相传理解工具定义,直接看 SKILL.md 和 OpenAPI schema 就能上手,等于把"机器可读的契约"同时变成了"人类可读的文档"。

6.2 不适合的场景与边界

必须诚实地说,这套方案也有明确边界。实时性要求特别高的场景不适合走一层 HTTP 转发,本地函数调用反而更快;工具接口极其简单且只有一两个时,为它建网站属于过度设计;涉及大量流式数据传输的场景,比如音视频处理,也不适合用这套结构化的反馈模式。另外,工具本身不稳定、第三方服务动不动就挂的场景,你再怎么把接口契约做干净也没有用,这时候该做的是服务降级和替代方案,而不是优化文档。

有一说一,这套方案治的是"语义不一致病"和"结构漂移病",治不了"下游服务根本不可用"这种硬伤。

最后再分享一点个人的实际体会。做这个项目之前,我总觉得 Agent 项目能不能成,取决于模型选得对不对、提示词写得精不精。做完了才意识到,给 Agent 用的网站和给人用的网站,完全是两种思维。人看页面会扫视、会脑补、会跳过不重要的部分;模型不会,它只会按字面理解,你给它多少信息它就基于多少信息做决策。你把 Agent 当一个严格执行协议的机器来对待,它的稳定性才会真正显现,而这段"把工具侧打扫干净"的功夫,比盲目堆提示词有效得多。后面我打算再给网站加一个技能监控面板,把每个 skill 的调用成功率、平均延迟、失败原因自动汇总成一份给模型看的摘要,让它自己学会优先选择更可靠的技能路径。这个方向还能延伸出很多玩法,不过目前这套架构已经让我手里的几个 Agent 项目从"战战兢兢"变成了"基本省心"。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进