一、工作进展

本阶段完成了 AI 对话后端与业务接口之间的工具调用层:按子域划分的 MCP 工具封装,以及配套的参数 JSON Schema 校验体系。这一层的目的是让 LLM 能够以规范、安全、可控的方式调用底层业务接口,同时在服务端保留对调用参数的最后一道防线。

主要产出:

  1. 三个子域 MCP 工具的注册表:身体、营养、训练

  2. 基于 JSON Schema 的参数 schema 自动生成与校验

  3. 统一的工具执行器:参数校验 → 路径变量填充 → HTTP 调用 → 结构化结果回传

二、详细内容

1. 为什么需要一层工具封装

在没有这层封装的前期草版里,LLM 是直接生成 HTTP 请求参数(方法、路径、body)的。这种做法能跑起来,但问题在于几周之后就逐渐暴露:

一是路径拼写错误。LLM 在生成接口地址时经常把路径拼错,多个斜杠、复数形式不一致、路径参数位置错位。每出一种错都要在 prompt 里加一句「注意某某路径是单数不是复数」,prompt 越积越长,效果越加越差。

二是参数结构漂移。LLM 对同一个接口有时候传对象、有时候传数组、有时候把路径参数塞进 body 里。接口在变更时,旧会话里的历史调用格式还在被模型当参考,进一步污染新的生成。

三是无法在服务端快速发现错误。一个错误的 HTTP 请求打到业务接口,通常会走过鉴权、参数反序列化、业务逻辑若干层,最后可能返回一个不明所以的 500 或 422。排查时要翻很多层日志。

这层工具封装的核心思路是:把对接口的描述从自然语言 prompt 迁移到结构化的 schema。模型看到的不再是「请调用训练记录接口,参数有日期、动作名……」这种文字描述,而是一份机器可读的 JSON Schema。这份 schema 直接作为工具参数给到 LLM 的 function calling 接口,LLM 输出的参数会被模型端先做一层格式约束,服务端再做一层校验,把错误拦在真正打业务接口之前。

从另一个角度看,这层封装也是对业务接口的一层适配。业务接口是为前端和传统调用方设计的,命名、参数形式、错误语义都以人类开发者为假设前提;而 LLM 作为调用方,有它自己的偏好和弱点。强行让 LLM 直接消费业务接口,会让 prompt 承担过多的「翻译」工作。工具封装层把 LLM 友好的参数表达(操作名 + 结构化参数)翻译成业务接口能接受的形态,让上下两层都按各自最自然的方式演进。

2. 按子域拆分工具

最早的版本里,所有业务接口被塞进了一个巨大的工具,工具的参数用一个包含几十个分支的联合类型列出所有可调用的业务操作。这个版本能用,但很快发现两个问题:

问题一:schema 规模失控

一个工具承载三个业务域全部接口,参数 schema 展开之后有几百行。LLM 每次调用都要把这几百行作为上下文处理,token 消耗高、响应慢,而且模型在长 schema 中选错分支的概率明显提高——经常出现用户在问营养问题、模型选了训练分支的情况。

问题二:跨子域污染

单一工具意味着模型可以自由在子域之间切换。意图识别模块负责同学的路由明明已经把当前请求分到营养子域了,但模型在生成工具调用时,仍然可能因为某个训练相关的历史上下文而「顺手」选了训练分支,破坏了意图路由的决策。

解决办法是把一个大工具拆成三个子域工具:身体、营养、训练。每个子域工具只包含自己子域的操作集合。调度时根据意图识别结果,只把匹配的那个子域工具 schema 给到 LLM,另外两个子域的 schema 模型完全看不到。

拆完之后,几个好处同时出现:

  • 单次调用的 schema 上下文减少到原来的三分之一左右

  • 模型能调用的范围和意图严格一一对应,跨子域污染被物理隔离

  • 子域之间可以独立演进,新加一个营养接口不会引起训练工具 schema 的变动,也不会影响其他子域的 prompt 稳定性

代价是每个意图都要选对应的工具 schema,调度逻辑比以前复杂了一点。但这个复杂度是一次性的、可封装的,相对于它解决的问题来说非常划算。

3. 参数 schema 的自动生成

每个子域工具下面挂着一张操作表:操作名、HTTP 方法、接口路径模板、必填字段、一句话描述。参数 schema 是从这张表自动生成的,不手写,原因是手写 schema 和手写接口定义极易走偏——接口字段改了、schema 忘了同步,线上就会出现「校验通过但业务接口报错」的诡异问题。

schema 生成逻辑做了两件事:

路径变量自动提取

接口路径里用花括号标注的变量(比如按日期查询的日期参数)会被正则扫一遍,自动提升为 schema 里的必填字段。这样新增一个带路径变量的接口,不需要再单独写一行「这个字段必填」的代码。

必填字段合并

接口的必填字段由两部分组成:路径变量一定必填,业务字段按接口定义必填。两者合并时按顺序去重,生成最终的必填字段列表。

选 JSON Schema 2020-12 版本而不是更常见的早期版本,主要是因为 2020-12 对联合类型的支持更成熟,对常量字段的约束也更严格。后面要用到的「操作名用常量精确匹配」的技巧,在早期版本的某些校验器实现上会有歧义。

4. 用联合类型 + 常量给 LLM 做参数"窄化"

子域工具的参数 schema 本质上是一个「在若干操作里选一个」的结构。最直白的写法是让参数带一个操作名字段,然后让 LLM 自己保证参数对象的形状和操作名对应。但这种写法实际效果很差——LLM 会把任意操作名的参数和任意参数对象组合到一起提交。

实际采用的方案是把整个参数 schema 展开成一个联合类型,每一个分支对应一个操作,分支里的操作名字段用常量锁死。这样 LLM 在生成参数时,必须先选中某个分支,选中后操作名的值就被锁死,参数对象的形状也被锁死——两者绑死,不可能错位。

这个技巧解决了「操作名和参数形状不一致」的核心痛点。一旦 LLM 选了某个分支,它生成的参数天然就是这个操作需要的结构,不需要 prompt 里反复强调「请保证操作名和参数对应」。

另外,每个分支都显式设置了「不允许额外属性」,不允许 LLM 传入 schema 里没有定义的字段。这一条规则看似严格,实际非常必要——如果不加,LLM 会偶尔塞一些自己「觉得有用」的字段(比如时间戳、原因码之类),这些字段在服务端被当作未知参数静默忽略,导致调用看起来成功但实际语义偏差。加上这条之后,任何未定义字段都会在 schema 校验阶段被拒绝,错误立即暴露。

5. 服务端二次校验

工具参数经过模型端的 function calling 约束后,理论上应该已经是合法的。但在工程上不能把校验完全托付给模型:模型有时会返回不符合 schema 的参数(特别是在 schema 很复杂时),不同模型厂商对 schema 的实现完整度也不一致。服务端必须做一次独立的二次校验。

二次校验走的是同一份 schema——这很重要,模型端看到的 schema 和服务端校验用的 schema 必须是同一份,否则两边对「合法参数」的定义会漂移。校验结果如果失败,不会静默报错或尝试修复,而是把校验器给出的完整错误路径和消息作为结构化错误原样返回给上层 Agent。这样 Agent 就能看到具体是哪个字段不合法、为什么不合法,甚至可以在下一轮对话里自己修复参数。

相比之下,「参数不合法时自动修复后继续执行」的做法看起来更友好,但实际非常危险——LLM 生成的参数如果有结构错误,往往反映的是它对业务语义的理解有偏差,这种偏差不修正,即使参数被自动补全了,调用的业务结果也大概率是错的。把错误显式暴露出来、让模型有机会在下一轮修正,反而是更可靠的策略。

6. 执行器的统一结构

所有子域工具最终都走同一个执行器。执行器拿到工具名、操作名、参数之后,依次做:

  1. 在对应子域的注册表里查到操作定义,没查到直接返回结构化错误

  2. 跑参数 schema 校验,失败返回结构化错误与错误路径

  3. 把参数里的路径变量填入接口路径模板,剩下的字段作为 request body

  4. 按方法类型打出 HTTP 请求:读方法不带 body,写方法带 body

  5. 处理超时与其他异常,统一包装成结构化错误

  6. 解析响应,无论业务成功失败,都返回一个统一的字典结构

这个统一结构对上层 Agent 非常友好。Agent 不需要关心底层是 HTTP、gRPC 还是本地函数调用,也不需要根据不同的错误类型写不同的分支。所有调用结果都是同一个形状:是否成功、响应码、请求路径、返回载荷。

执行器里还做了一件小事:请求的鉴权 token 由上层 Agent 透传进来,不由执行器自己去环境变量里拿。理由是同一个对话里工具调用可能跨多轮,每一轮都需要用当前用户的 token,而不是进程启动时读到的某个默认 token。这件事说起来很小,但如果一开始没设计好,改起来会是侵入式的。

7. 与 Agent 架构的边界

这一层工具封装是从「业务接口」到「LLM 可调用工具」的桥梁。Agent 架构模块负责同学在上层负责意图分类、工具选型、多轮编排、流式输出;Prompt 工程模块负责同学在系统提示词里告诉模型什么时候调哪个工具、参数约束有哪些。工具封装层提供的是最底下一层:给 LLM 看的 schema、给业务接口打的 HTTP、中间的参数校验

边界划清之后,三方可以独立演进。新增一个业务接口,我这边只要在注册表里加一行;意图识别新增一个标签,调度层只要在分发逻辑里多一条路由;prompt 优化不会影响工具本身的实现。这种「薄而明确的边界」是整个 Agent 链路能快速迭代的前提。

分层清晰之后,还有一个隐形收益:排查问题的路径变短了。一个对话出错的时候,可以分别看三个点——意图是否识别对、工具参数是否生成对、业务接口是否按预期响应。每一层都有自己明确的输入输出结构,问题几乎一定发生在某一层,不会出现「三层都可能有问题、每层看起来又都正常」的模糊情况。

三、总结

工具封装层看起来只是一个「把 HTTP 接口包一层给 LLM 调」的中间层,但工程上要解决的问题比想象的多:参数约束如何让模型理解、schema 怎么不和接口定义漂移、错误如何结构化回传、多个业务域如何在一个 Agent 里共存。

几个核心的设计决策:

  1. 按子域拆工具而非单一大工具:用物理隔离换 schema 简洁度和跨域纯净度,代价是调度层复杂一点

  2. 联合类型 + 常量锁死操作与参数的对应:不靠 prompt 约束、不靠事后校验,而是在 schema 结构层面就让「操作名和参数结构不一致」不可能发生

  3. 严格禁止未定义字段:通过 schema 规则把 LLM「自作主张」加字段的情况堵死,所有异常都在 schema 阶段就能暴露

  4. 服务端二次校验用同一份 schema:避免前后定义漂移,错误以结构化形式返回而不是自动修复

  5. 执行器统一结构输出:让上层 Agent 不用关心底层协议,所有调用结果同构

工具层做扎实之后,上层 Agent 的 prompt 才能真正精简——不再需要靠「请注意路径是单数」这种琐碎的约束堆砌。下一阶段的重点会转向这层之上的实际调用流程打磨。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐