经常有人问我一个问题:一个Agent项目里的Tool、Context和输出合约,单拎出来讲好像都挺简单,工具就是个函数列表,上下文就是把聊天记录塞进去,输出合约无非是定义个JSON格式。但是真到了组装那一步,各种幺蛾子就冒出来了——工具明明注册了但模型就是不调,调用了结果却没进上下文导致第二轮就"失忆",输出一会儿符合合约一会儿不符合。我这一期来聊聊怎么把这三个零件正确拼起来。
我这边有个内部项目代号叫Pi,做的就是一套轻量级Agent脚手架。第一篇已经把三个模块的整体思路铺开了,这一篇是实打实的组装篇:你会看到我为什么把它们的安装顺序定成"输出合约→Tool注册表→Context编排",也会看到装完之后最容易翻车的三个接缝分别在哪。如果你也在搭自己的Agent工具链,或者已经在用市面上的框架但总觉得"卡卡的”,这篇应该能帮你少踩几个坑。
1. 为什么组装比制造三个零件更难
1.1 三个各司其职的零件
先对齐一下我说的是哪三样东西。
Tool是模型能调用的外部能力,查天气、读数据库、发请求都算。它本质上是一组"动作",模型决定要不要做、什么时候做。
Context是模型在生成回复那一刻"看得到"的全部信息,包括系统约束、对话历史、工具返回的结果。它决定了模型的视野。
输出合约是模型输出必须遵守的结构约定,通常是JSON Schema或类似的格式约束。它保证了产出去的东西能被下游程序直接消费。
听起来边界很清晰,对吧?但是组装的时候你就会发现,这三个东西根本没有清晰边界,它们是互相咬合的齿轮:
- 工具要不要被调用,取决于模型在Context里读到了什么;
- 工具调用完之后的结果,要回流进Context才能让后续对话不短路;
- 输出合约约束了最终答案的结构,但这个结构往往要承载工具结果的数据;
- 而工具本身在定义阶段就带着自己的参数Schema,这个Schema和输出合约里的字段可能还是同一份数据的两份表达。
这就是第一个难点:它们是循环依赖的,没法按"先做A再做B再做C"的线性路径来开发。
1.2 组装的核心是先定数据流向
我踩过一轮坑之后得到的结论是:组装的第一步不是写代码,而是把"数据是怎么流过去的"画出来。你不需要画专业的架构图,拿张纸或者备忘录写清楚这几个方向就行:
- 用户输入进入Context;
- 模型根据Context决定调用某个Tool;
- Tool参数从哪来(模型生成的参数还是上游数据快照);
- Tool结果回传到哪(进入Context的哪个层级);
- 最终输出通过哪套合约校验。
这五条链路里任意一条断开,整体就会出问题。尤其是第三条,很多人写Tool的时候默认参数应该由模型生成,但在真实的复杂任务里,有些参数应该从Context里取,根本不该让模型自己捏。这个分歧不提前对齐,装完就要返工。
1.3 组装顺序为什么是"合约→Tool→Context"
我实际的组装顺序是:先输出合约,再Tool注册表,最后接Context编排。
原因在于,输出合约是唯一一个"所有下游消费者都依赖"的接口。你的程序要解析它,别的模块要消费它,工具结果可能要塞进它的字段里。把它先定下来,等于给整个系统钉了一个锚点,后面所有模块都对着这个锚点设计。
Tool排在第二位,是因为工具的输入输出格式要根据合约来调。比如合约里定义了data字段必须是一个对象,那么工具的返回值就要想办法落进这个结构,而不是自创一套格式。Context反倒是我最后接的,因为它更像一个"承载层":前面两个定了,Context只需要把数据组织好送进去就行。
这个顺序跟很多人的习惯相反,大多数人喜欢先做工具,再想输出格式,最后发现输出跟工具结果对不上,再回头改工具。改来改去全是痛苦。先定合约,后面顺很多。
提示:如果你的项目里已经有一套写好的工具,来不及按这个顺序重来,至少先把输出合约定好再动Context,效果也会好很多。
2. Tool 注册表:给模型准备一只好用的"手"
2.1 注册表条目不只是"函数指针"
很多初版设计里,工具注册表就是个map:名字到函数的映射。模型说要调用query_stock_price,系统就执行这个名字对应的函数。它能跑,但仅此而已。
真正可用的注册表,每条记录至少要有四个部分:
- 标识字段:name、version,用来唯一定位;
- 描述字段:一份给模型看的自然语言说明,解释这个工具是干嘛的、什么时候该用、什么时候千万别用;
- 参数合约:一份JSON Schema,描述工具需要哪些参数、参数类型、哪些必填;
- 执行配置:超时时间、重试策略、权限级别。
其中的参数合约,我建议直接复用输出合约的Schema定义方式,别单独发明一套。后面你会经常遇到工具参数和输出字段涉及同一份数据的情况,如果两边Schema风格不一致,联调时能别扭死。
2.2 工具描述的"语义匹配"陷阱
工具描述是整个Tool模块里投入产出比最高的地方,因为这个描述的质量直接决定了模型会不会正确选择工具。模型做工具选择时,本质上是"语义匹配":拿用户的当前诉求去比对所有工具的描述文字,谁语义最接近就选谁。
我见过一个反例:有个工具是用来查询"订单物流状态"的,开发者的描述只写了"查询订单物流状态"。看起来没问题,但用户说"我的快递到哪了"的时候,模型在语义匹配上会犹豫,因为"快递"和"订单物流"之间隔着一层。后来描述改成"查询订单/快递的物流进度,当用户询问包裹走到哪一站时使用",调用率立刻上来了。
更关键的是,描述里一定要写"什么时候别用"。负例比正例更能帮模型划清边界。还是那个物流工具,我在描述末尾加了一句"仅限查询已发货订单,不要用于下单或退款",误调用率明显下降。这属于典型的"描述文案是高性能零件"的场景。
2.3 工具返回值的双轨设计与回流接口
工具结果的格式,我在实践中总结出一个双轨方案:原始数据 + 摘要文本。
原始数据是结构化的,给程序消费;摘要文本是自然语言的,给模型当"记忆"用。为什么这么拆?因为模型读工具结果时,最擅长的是读一段通顺的文字,而不是去解析一个巨大的嵌套JSON。我实测过,把查询结果压成一段"xx股票当前价格是yy,较昨日上涨zz%"的摘要文字给模型,它后续推理的准确性比直接丢原始JSON要高不少。
同时,原始数据要保留在Context的Workspace层里,防止下游程序需要时拿不到结构化数据。双轨的好处是:模型读得懂,程序查得到。
2.4 工具失败时的"沉默"是最坏的处理
工具会失败,这是常态。但很多初版实现死在:工具失败后只记录了一个日志,返回给模型的是"执行失败"。模型收到这种信息,轻则道歉一次接着猜答案,重则直接进入幻觉模式。
我的建议是,工具失败时也要返回一份结构化的失败报告,至少包含:错误类型、可理解的错误说明、下一步建议。比如"查询超时,原因:上游服务无响应,建议稍后重试或改查缓存数据"。这个报告作为工具结果回流进Context,模型就能基于真实情况继续推理,而不是自己脑补。
提示:工具失败报告本身也是一种工具结果,要走和成功结果一模一样的回流通道。如果你单独开一条错误通道,等于给Context引入了一个新变量,很容易漏处理。
3. Context 编排:决定模型此刻"看得到什么"
3.1 Context 的三层结构划分
我把Context拆成三个层次:System层、Workspace层、Dialogue层。
System层存放不可变的基础指令,包括角色设定、输出要求、工具使用守则。它相当于是模型的行为基线,我在任何层级读取和裁剪策略里都不会动它。
Workspace层存放任务运行过程中的动态数据,包括工具结果、外部数据快照、临时状态。这一层是组装的重点,因为它的内容更新最频繁,也最容易乱。
Dialogue层存放多轮对话历史,是模型理解用户上下文的主要来源。
这三个层次分开存,但拼接的时候按固定顺序合成一条消息序列:System在前,Workspace居中,Dialogue在后。为什么Dialog放在最后?因为越是靠近末尾的消息,模型注意力越强。最近的一轮对话是什么、用户刚刚问了什么,这些比时序靠前的历史数据更需要模型的强注意力。
3.2 有限的窗口,需要明确的设计取舍
上下文窗口是有限的,你没法把所有东西都塞进去,所以必须做预算分配。我习惯的比例大概是这样:
- System层占10%-15%,固定不动;
- Workspace层占30%-40%,里面优先保证最近的工具结果和最关键的快照数据;
- Dialogue层占40%-50%,按时间倒序保留,旧消息先行裁剪。
真正执行裁剪的时候,优先裁Dialogue层中最古老的消息,因为它们和当前任务的相关度最低。Workspace层宁可压缩也很少直接裁掉,因为工具结果往往比历史对话更有价值。
压缩Workspace层的手段不是"截断文本",而是"生成摘要"。把原始结果丢给一个轻量任务做梗概,再把梗概放进Context。你可以单独用一次快速的模型调用,也可以复用最近一次工具结果的处理链路。摘要生成会带来额外延迟,但换来的是在整个上下文窗口里放进更多有效信息,这笔交易我认为值得。
3.3 工具结果回流:不做这一步,工具等于白调
这是Tool和Context之间最重要的接缝。
模型在一次请求里调用了工具,拿到结果后生成了回答。到了下一轮,用户问"那xx股票呢",如果系统没有把上一轮的工具调用记录和结果回流到新一轮的Context里,模型就会完全忘记自己刚刚查过什么,更糟的是,它还可能为了不丢脸而顺着用户的问话编造一个价格。
我最初搭建Pi时,曾经把工具调用记录放在临时变量里,用完即弃,结果就是多轮场景下一片幻觉。修复极其简单:每一轮组装Context时,把"调用过的工具名+参数+结果摘要"作为一个工具历史块放到Workspace层,下一轮模型就还记得。
你可以看一下这个回流的数据结构:
{ "tool_history": [ { "tool": "query_stock_price", "params": { "symbol": "AAPL" }, "result_summary": "AAPL当前价格188.32,较昨日上涨1.2%", "timestamp": "2025-01-15T10:30:00Z" } ] }这个块不需要特别大,摘要文本足够模型明白"查过了、结果是什么"。关键是必须有,并且要按顺序追加,不能覆盖。
3.4 给Context里的数据都戴上"时间戳"
Context里的数据必须带时间戳,这一点在组装的时候最容易漏。
原因很简单:模型对"数据新旧"没有感知,它看到一段文字就默认这是当前状态。如果工具的查询结果是昨天的,而它没被标注,模型就会拿昨天的情况回答今天的问题,而且答得理直气壮。
我的做法是在每个Workspace的条目里强制挂一个时间戳或者有效期字段。在组装Context时,如果发现某个工具有效期已过,要么重新拉取,要么在字段旁边显式标注"数据可能过期"。至少让模型知道,这个数据不是新的。
提示:时间戳的格式要和系统里其他地方的格式统一。我遇到过同一个时间戳在日志里是Unix秒、在Context里是ISO字符串,导致排查问题的时候对不上,这种事看着小,折腾起来很烦。
4. 输出合约:把模型的回答从"可读"变成"可用"
4.1 合约的核心价值是"可以被程序消费"
自由文本输出是人类用户的最爱,却是程序系统的噩梦。下游要接的如果是UI界面还好说,但如果你要做的是自动化流程、数据入库、跨系统调用,那模型输出的每一个字段都必须能被程序按固定逻辑解析。
输出合约本质上是和模型之间的一份"接口承诺":你承诺输出一定结构的数据,我承诺按这个结构解析。这里的"契约"不只是JSON Schema本身,还包括自然语言说明和示例,三者缺一不可。
4.2 合约 = Schema + 自然语言说明 + 样例
我只给模型看一份JSON Schema的日子早就过去了。现在我的输出合约是三位一体的:
- Schema:字段名、字段类型、必填约束;
- 自然语言说明:最关键的字段含义和使用场景,用人类语言写清楚;
- 样例:一到三个完整的示例输出。
为什么自然语言说明重要?因为JSON Schema是给程序看的规约,但模型的实际理解是语义级的。你需要告诉模型:这个字段代表什么、什么时候填什么值、如果没数据别硬编。
举例来说,对于一个回答类任务的输出合约,我会把它组织成这样:
{ "type": "object", "properties": { "answer": { "type": "string", "description": "面向用户的自然语言回答,保持简洁,不超过200字" }, "data": { "type": ["object", "null"], "description": "承载工具返回的结构化结果;当没有工具数据时,此字段必须为null,不能伪造" }, "confidence": { "type": "number", "description": "0到1之间的置信度,表示模型对本次回答的确信程度" } }, "required": ["answer", "data", "confidence"] }注意看Schema里的description字段,它其实承载了自然语言说明的一部分职责。我会在System层再加一段更细的文字说明,比如"当需要查实时数据时必须先调用工具,工具结果统一放在data字段中"。
样例的作用是给模型做格式参照。模型在few-shot场景下对格式的把握远好于只看Schema时的表现。每个合约至少配一个完整样例,最好是带真实数据的样例,而不是占位符。
4.3 校验失败时的分级处理
模型输出不可能永远一次通过校验。这里我建议不要用"错了就重试"这种一刀切策略,而是分级处理。
第一级:可修复错误。比如某个可选的字段类型不对,或者text里多了个换行。这类错误直接修复,不重试。修复可以是规则的:如果类型不对就尝试强制转,如果字段缺失但不是必填就补null。
第二级:需重试的错误。比如必填字段真的缺失,或者整体结构偏离合约。这种情况我会带着第一次的错误信息重新让模型生成一次,要求"上次输出未通过格式校验,请严格对照Schema重新输出"。刚才说过的工具失败也类似。
第三级:降级处理。如果重试了两次还是不行,就放弃合约,把输出连着一个"解析失败"的标记一起返回,让上层系统决定怎么处理。
这套分级策略帮我避免了很多无谓的重试开销。合约校验失败的对话往往集中在少数必填字段上,能修的修掉,能补救的补救,比每次都重新让模型生成一遍省钱也省时间。
5. 组装顺序复盘:三个最容易翻车的接缝
5.1 推荐组装步骤:合约 → Tool → Context
如果你从头搭一套,我的推荐顺序是:
- 先把输出合约钉死,作为全系统的接口锚点;
- 再建Tool注册表,让每个工具的输入和返回都能映射到合约的字段上;
- 最后搭Context编排层,把工具结果通过统一格式回流进上下文。
这套顺序不是随便排的,它的核心逻辑是:先确定"进出口"的规格,然后确定"手"要拿什么数据,最后再考虑"眼"怎么看。我试过反着来的方案,也就是先做Context再定合约,结果是每次合约一改,Context的拼接逻辑就要跟着改一遍,非常伤。
5.2 接缝一:Tool结果没写回Context
这是我踩过最深的坑,已经在上文详细展开过。现象是:每次单轮对话看起来都对,但用户连续追问两句模型就开始胡编;排查的时候发现工具结果只存在于那一次调用的局部作用域里,根本没有进入下一轮的上下文。
排查链路是这样的:
- 先看模型的第一轮回答是否依赖工具结果,如果是,但第二轮开始编造,多半是第二轮没有看到工具记录;
- 再查上下文组装的地方,确认tool_history有没有被拼进去;
- 最后看数据是否来自正确的作用域。
修复也简单,把工具调用记录和结果摘要追加到Workspace层,每轮刷新时保留即可。
5.3 接缝二:工具参数Schema和输出合约Schema打架
当你把工具结果塞进输出合约的data字段时,会碰到一个恼人的问题:工具返回的字段名和合约里定义的不一致。比如工具返回last_price,合约却要求data.price。程序解析不出来。
这个问题根因在于工具的参数合约和输出合约是两套独立写的Schema,没有共享底层定义。我的修复方法沉淀成一条:共用一份数据模型定义。凡是既会出现在工具参数里又会出现在输出结果里的数据实体,定义一份绝对的Schema,两边引用,不许各自抄一遍。
排查的时候,你会看到解析器报了字段缺失错误,但翻工具结果发现数据其实是有,只是一个叫a一个叫b。这种问题用代码查看不出大毛病,就是命名错位。
5.4 接缝三:Context裁剪把System层裁没了
Context一紧张,代码就开始按优先级裁剪,结果某天模型突然不遵守角色设定和输出要求了。排查了一下午,发现是裁剪逻辑把System消息当成了普通消息,按时间戳排序后因为"太久远"给裁了。
System层的消息必须设置为不可裁剪,任何情况都不能移除。它承担的是模型行为约束,被裁掉之后,你之前精心写的角色设定和工具守则就全废了。修复很机械:把System层单独归类,优先级设为最高,裁剪时永远跳过。
5.5 一张组装验收清单
最后给你们一张我在Pi项目上实际使用的验收清单,装完之后逐条过一遍,能过滤掉大部分问题:
- 输出合约是否已定,且所有下游模块都引用同一份定义;
- 每个工具的描述是否包含"什么时候用"和"什么时候别用";
- 工具结果是否按双轨方式(原始数据+摘要文本)回流进Context;
- Context是否分层(System/Workspace/Dialogue)且System层不可裁剪;
- 模型在下一轮对话中能否复述上一轮工具结果的关键内容;
- 合约校验失败时是否走分级处理,而不是无脑重试;
- 工具参数Schema和输出合约共用同一份实体定义。
把这些过一遍,基本不会出现"单测全过、组装全崩"的尴尬。
我个人在组装完这一轮之后最深的体会是:这三个模块单独拿出来看,全是小问题;真正决定一个Agent脚手架好不好用的,是接缝处有没有被认真对待。工具结果的回流、Schema的共用、System层的保护,这些都是看不见但是要命的东西。希望你组装的时候,不用再重新踩一遍。