ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Agent内核组装:Tool、Context与输出合约全解析

Agent内核组装:Tool、Context与输出合约全解析

接上回。Pi 这个项目已经能和大模型对话了,但说实话,上一版充其量是个“聊天壳子”——模型很能说,却什么都做不了。这一篇要干的事很明确:把 Tool、Context 和输出合约这三块拼起来,让 Pi 从“会聊天”变成“能办事”。这也是一般 Agent 内核最核心的骨架,做完这一轮,Pi 才算真正被“组装”出来。

如果你也在从零搭 Agent,被函数调用、上下文塞爆、输出解析搞到头大,这篇的踩坑记录应该能帮你少走不少弯路。

1. 组装前的整体思考:为什么是这三件套

1.1 Tool、Context、输出合约各自解决什么问题

一个能帮用户干活的智能体,光有模型肯定不够。模型本质上是一个“预测下一个词”的机器,它擅长的是生成文本,不是执行动作。要让 Pi 查天气、写提醒、调接口,就必须给它一个外挂的“工具箱”,也就是 Tool 层。

但人干活不光要有工具,还得知道上下文。用户十分钟前说过“明天我要去爬山”,现在问“那还要不要带伞”,Pi 如果只看当前这句话,根本不知道“那”指的什么。所以 Context 层要负责把环境信息、历史对话、工具返回结果打包成模型能理解的消息序列。

有了工具、有了上下文,模型终于能输出结果了。可它输出的是自然语言,口语化的、措辞飘忽的。下游程序要拿这个结果去创建提醒、写入数据库,总不能靠正则硬抠。输出合约就是干这个的:强制模型按约定好的结构化格式交付,能校验、能兜底,出错还能重试。

打个比方,这就跟外包助理干活一样:你得给他电脑(Tool)、一份背景简报(Context)、一个报告模板(输出合约),他交回来的东西才能直接用。

1.2 组装顺序与依赖关系

这三层不是并列关系,是有依赖链的。我的组装顺序是这样的:

  1. 先确认 LLM 接口支持函数调用(Function Calling / Tool Use),这是地基。
  2. 搭好 Tool 注册中心,把外部能力抽象成统一的工具描述。
  3. 再写 Context 组装器,把历史消息、用户输入、工具结果按顺序合并成 messages 数组。
  4. 最后套输出合约,把“模型最终回话”也定义成一个结构化动作,保证下游能解析。

为什么是这个顺序?因为每一层都依赖下面一层。输出合约要管住的是“最终决策”,而决策又来自上下文里的工具结果,工具调用又得靠 Tool 层去执行。倒着来很容易返工,先把最底层的工具跑通,再一层层往上盖,出了问题也知道去哪查。

1.3 为什么我不直接用一个“全家桶”框架

现在社区里那些重型 Agent 编排框架看着很香:拿来就能用,自动帮你管记忆、管工具、管规划。但我的观点是:项目早期,尤其是想搞明白 Agent 到底怎么运转的时候,别一上来就套框架。

原因很简单:框架把太多细节封装了,工具调用循环、消息拼接、上下文截断都隐藏起来。一旦出问题,你连日志都不知道该往哪看。而且框架的抽象往往绑定了它自己的数据结构和消息格式,换成别的模型、别的接口,又要适配一层。

我选择的是最朴素的一条路:不依赖框架,直接把“消息数组”当作唯一抽象。任何支持函数调用的大模型接口,本质上都是往里塞消息、往外取消息,组装逻辑是一样的。自己手搓一个百来行的内核,跑明白之后再决定要不要引入外部工具,心里会踏实很多。这套思路在后续给 Pi 加各种扩展能力时,也足够灵活。

2. Tool 工具层:让 Pi 有手可用

2.1 工具注册中心设计

工具层的第一件事,是设计一个统一的注册中心,所有外部能力都以“描述 Schema + 执行函数”的形式注册进去。关键在描述 Schema,它长这样:

WEATHER_TOOL = { "name": "get_weather", "description": "查询指定城市在指定日期的天气情况,返回天气现象、温度、风力等。当用户询问当前或未来某天天气时使用。", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名称,如:上海"}, "date": {"type": "string", "description": "日期,格式 YYYY-MM-DD,缺省表示今天"} }, "required": ["city"] } }

description 是给模型看的,不是给人看的。模型决定要不要调这个工具、传什么参数,全靠这几行描述。很多时候模型“该调不调”,不是模型傻,是描述写得太含糊。

配套的注册类也很简单,数据结构上用字典存储,执行时直接映射到 handler:

class ToolRegistry: def __init__(self): self._tools = {} def register(self, schema, handler): name = schema["name"] self._tools[name] = {"schema": schema, "handler": handler} def list_schemas(self): return [t["schema"] for t in self._tools.values()] def execute(self, name, arguments): if name not in self._tools: raise KeyError(f"unknown tool: {name}") # 实际项目里参数校验放在这里做 return self._tools[name]["handler"](**arguments)

handler 就是普通 Python 函数。查天气就去请求第三方接口,创建提醒就写数据库,封装成一个函数即可。唯一要注意的点是:handler 返回的一定要是字符串,并且要控制长度。模型只认字符串,而且它要把这段内容重新读到上下文里。

2.2 工具调用循环的实现

模型不是直接返回“我调用了 get_weather”,而是返回一个结构化的 tool_calls 指令,里面包含工具名和参数。应用这边拿到指令后执行工具,再把结果作为一条 tool 角色消息塞回消息列表,让模型基于工具结果接着思考。

这个循环就是 Agent 核心中的核心:

def run_agent(system_prompt, user_input, history, registry, max_steps=5): messages = [{"role": "system", "content": system_prompt}] messages.extend(history) messages.append({"role": "user", "content": user_input}) for _ in range(max_steps): resp = llm.chat(messages, tools=registry.list_schemas()) assistant_msg = resp.message messages.append(assistant_msg) if not resp.tool_calls: break for call in resp.tool_calls: result = registry.execute(call.name, call.arguments) messages.append({ "role": "tool", "tool_call_id": call.id, "content": result }) return messages

循环终止条件有两个:模型不再要求调工具,或者达到最大步数。后一个条件非常重要,所有 Agent 框架里都必须设上限,不然碰上模型反复调用同一个工具,你的账单会非常感人。

有一点值得说明:现在很多模型支持一次返回多个工具调用,上面代码里的 for 循环天然支持串行执行。理论上可以并行,但涉及共享状态、并发安全,建议先做串行,跑通了再去优化。

2.3 工具描述细节决定成败

工具描述写不好,后患无穷。我在 Pi 上踩过的坑总结成几条:

  • description 要写“什么时候用”。比如“当用户询问天气时使用”,比简单写“查询天气”命中率高得多。
  • 参数命名别偷懒。用 city、date 这种直观的词,不要用 c、d 这种缩写。
  • 参数类型和枚举要写清楚。枚举值能极大减少模型瞎传参的概率。
  • 给 description 加一两个典型示例。例如“查询天气时使用,常见参数:上海、2025-01-01”,模型会更容易理解。
  • 工具数量别贪多。一次给模型塞二三十个工具,上下文窗口吃紧不说,模型还容易选错。建议做分组,先按意图路由到某个工具集合,再让模型在该集合里选。

我在早期版本里有个典型错误:同时注册了两个功能相似的工具,一个叫 get_weather、一个叫 weather_forecast,description 也没区分清楚。结果模型有概率选错。后来干脆合并成一个工具,用参数区分查询内容,问题立刻消失。工具设计原则应该是:能合并的合并,能拆分的拆分,每个工具都要有清晰的边界。

3. Context 上下文层:给 Pi 塞进足够信息

3.1 上下文五要素

每次请求模型时,最终发出去的 messages 数组不是简单拼接,它要包含五类信息:

  • System 提示词:角色设定、行为准则、输出格式约束、安全边界。
  • 用户当前输入:当下这一轮的问题。
  • 历史对话:之前几轮的 user / assistant 消息,按时间顺序排列。
  • 工具结果:最近一次或几次工具调用的返回内容。
  • 环境信息:当前时间、用户所在城市、可用权限等。

组装顺序也讲究。System 永远在最前面,它是对整体行为的约束;工具结果必须紧跟对应的那个 assistant 工具调用消息,不能乱序;历史对话按时间从前到后排,最新的排后面。顺序一旦错乱,模型对“当前发生了什么”的感知就会错位。

3.2 Token 预算与控制策略

大模型的上下文窗口是有限的,几十万 token 只是理论值,实际使用中塞得越多,响应越慢、越贵、越容易“忘事”。我建议把上下文当预算来管理,提前规划好每一块花多少 token。

我的 Pi 默认策略大概是这样的:

  • System 提示词固定占用一部分,控制在 500 token 以内。
  • 最近 4 到 6 轮历史对话完整保留。
  • 更早的历史进入摘要队列,压成一两句话。
  • 工具结果只保留关键信息,超长内容先截断再拼进去。
  • 环境信息(如当前时间)只保留必要字段。

这样做的本质是把长期记忆和短期记忆分开。模型就像一个人,短期记忆窗口有限,但可以靠“便签”和“笔记本”来弥补:便签是当前的会话摘要,笔记本是外部的持久化存储。

3.3 上下文污染与信息过载

上下文管理的难点不在于“怎么塞”,而在于“别塞错、别塞太多”。工具返回的结果是最容易污染上下文的东西。

有一次我给 Pi 接了一个数据查询工具,返回的是一个几十 KB 的原始 JSON。模型收到之后,全员精力都在复述 JSON 上,完全忘了用户真正问的是什么。而且这份超大 JSON 直接把 token 预算吃掉了大半,后续轮次模型几乎是从碎片里往外挤句子。

教训就是:工具在执行端就要做结果清洗。能摘字段就摘字段,能聚合就聚合成一句话。比如天气查询返回原始 JSON 没关系,但工具 handler 内部先转成“上海明日多云,18-24 度,东北风 3 级”,再把这个摘要文本发给模型。模型看到的是结论,不是数据结构。

上下文组装器最好也加一层保护:任何单条工具结果超过一定长度(比如 1000 字符),先做截断或摘要,不要原样入栈。

4. 输出合约层:让 Pi 交付可解析的结果

4.1 为什么不能直接拿自然语言当输出

自然语言是给人看的,不是给程序看的。Pi 的很多下游动作,比如创建提醒、写订单、更新状态机,都需要拿到结构化的字段:订单号、金额、状态、时间。如果模型输出一大段自然语言,你还得写一堆正则、关键词匹配去“猜”字段,想想就头大。

更麻烦的是,模型的自然语言输出极不稳定。同一个意思今天说“好的,提醒你一下”,明天说“没问题,已经设置提醒啦”。程序没法在这种飘忽不定的输出上建立可靠逻辑。

4.2 三种输出约束方案的选型

我试过三种方式,各有适用场景:

方案做法优点缺点
Prompt 约束在 System 里写“请用 JSON 返回,字段为 xxx”什么模型都能用,门槛最低格式经常不严格,解析易失败
JSON 模式调用支持结构化输出的大模型接口,强制生成合法 JSON能保证“是 JSON”,但字段结构不一定符合预期依赖模型提供方能力
工具化输出把最终回答声明成一个伪工具,用函数调用的机制强制结构化能复用成熟链路,字段校验最强实现稍复杂,模型必须支持函数调用

我最推荐的是第三种:工具化输出。因为它的链路和普通工具调用完全一致,模型对“该输出什么字段”的理解非常清晰,而且是强约束,比写 prompt 可靠得多。

4.3 输出合约定义与校验

输出合约本质是一份 JSON Schema。以 Pi 的“最终回答”为例,我会注册一个名为 final_result 的工具:

FINAL_RESULT_TOOL = { "name": "final_result", "description": "当任务完成时调用,用于返回最终答复和结构化业务数据。任何任务最终都必须调用本工具。", "parameters": { "type": "object", "properties": { "answer": { "type": "string", "description": "给用户的最终回复,自然语言,简洁友好。" }, "structured_data": { "type": ["object", "null"], "description": "可选的业务数据,如提醒信息、订单信息。" }, "next_actions": { "type": "array", "items": {"type": "string"}, "description": "建议的后续动作。" } }, "required": ["answer"] } }

这里的技巧是让“告诉用户的话”和“给程序的数据”解耦。answer 字段保留自然语言,发给用户看;structured_data 是结构化字段,发给程序处理;next_actions 可以给流程编排用。

光有 schema 还不够,输出拿到之后必须校验。我在 Pi 里手写了一个小的递归校验器,检查字段类型、必填项、枚举值。校验失败有两种处理路径:第一种是重试一次,把校验错误信息反馈给模型,让它修正;第二种是兜底,比如 structured_data 不符合要求就置空,同时记一条日志。

4.4 输出合约与安全兜底

输出合约为我们提供了一层很关键的安全兜底。没有合约的时候,模型偶尔会“拒绝”执行逻辑,或者输出一些压根没法解析的内容;有了合约,程序可以检测到“模型没按约定返回”,从而走重试或降级流程。

我见过一些项目把输出合约理解得很窄,以为只是“JSON 化”。其实合约真正解决的是“契约”:双方约定好结构,模型有义务遵守,程序有权利校验。这和接口文档的作用一模一样,属于两个人协作时最基本的信任机制。

5. 完整组装实战:最小闭环

5.1 场景与目标

光讲理论没用,我直接拿 Pi 上一个完整的小场景走一遍。需求:用户说“帮我查一下明天的天气,如果下雨就提醒我带伞”。目标:

  • Pi 调用一次天气工具拿到明日天气。
  • 判断是否为雨天。
  • 如果是雨天,调用创建提醒工具,写入一条提醒。
  • 最终返回一条自然语言回复,外加结构化数据。

5.2 组装过程代码

先注册两个工具:

def get_weather(city, date=None): # 实际项目里这里去请求天气服务,返回值转成摘要文本 return f"{city},{date},小雨,18-24 度,东北风 3 级" def create_reminder(content, time): # 实际项目里这里写入提醒数据库,返回提醒 ID return f"已创建提醒 #{12345}:{content},时间 {time}" registry = ToolRegistry() registry.register(WEATHER_TOOL, get_weather) registry.register(REMINDER_TOOL, create_reminder) registry.register(FINAL_RESULT_TOOL, final_result_handler)

注意 final_result 也注册进去了。它没有真正的执行动作,函数体就是返回传入的参数,交给上层逻辑去处理最终结果。

然后组装 Context:

class ContextBuilder: def __init__(self, system_prompt): self.system_prompt = system_prompt self.max_history_rounds = 4 def build(self, user_input, history, env): messages = [{"role": "system", "content": self.system_prompt}] messages.extend(history[-self.max_history_rounds * 2:]) messages.append({ "role": "user", "content": f"当前时间:{env['now']}\n用户问题:{user_input}" }) return messages

最后把循环跑起来。下面是一次真实的消息演变过程(简化):

第一轮:user 说“查一下明天的天气,如果下雨就提醒我带伞”。模型返回 tool_calls 调用 get_weather,参数是城市和日期。 第二轮:把天气结果文本作为 tool 消息塞回。系统提示里规定“如果出现降水,调用创建提醒工具”。模型根据结果判断是雨天,于是调用 create_reminder。 第三轮:把“提醒创建成功”的 tool 消息塞回。模型看到任务已完成,调用 final_result。

final_result 返回后,程序解析出:

{ "answer": "明天上海会下雨,记得带伞哦。提醒我已经设置好了。", "structured_data": { "reminder_id": 12345, "content": "带伞", "time": "2025-01-02" }, "next_actions": [] }

到这里,Pi 完成了一次真正的“干活闭环”:不是模型单方面输出,而是模型决策、工具执行、上下文反馈、最终结构化交付。

5.3 实测数据与调优

我把这组流程跑了几十轮,记录了一些数据:

  • 平均每轮任务需要 3 次模型调用(决策、工具反馈后再决策)。
  • 消耗 token 大约在 4k 到 6k,其中工具结果会比对话文本更吃 token。
  • 输出合约启用后,解析成功率从大约 80% 提升到 98% 以上——剩余 2% 主要是模型调用 final_result 时参数格式偶发错误,但重试一次基本都能修回来。

成本优化方面,有个非常实用的点:可以在进入完整循环之前,先加一个“意图路由”层,用小模型判断用户是否需要工具。如果只是闲聊,直接走普通对话,不进工具循环。这样既省 token,又避免模型误调工具。Pi 升级到这一步之后,闲聊请求的响应时间和成本都下降了一截。

6. 踩坑记录与速查表

组装过程中踩了不少坑,整理出来供参考。

6.1 六个最常见故障

症状原因解决方案
模型该调工具不调工具描述太模糊,或不在 system 明确要求描述加触发场景,System 里写“需要时务必调用工具”
工具参数类型报错模型传错类型,或必填项缺失参数 Schema 加枚举、类型,handler 里做防御性校验
工具结果太长原始数据未经清洗直接入栈工具端先聚合、截断,只输出结论摘要
模型复述工具原文没有明确“工具结果只是参考”System 提示“不要复述原文,只提炼结论”
final_result 解析失败模型没用合约输出,或者参数缺字段校验失败后把错误信息回传给模型重试
循环卡死、反复调用同一工具缺少步数上限强制 max_steps,超时直接返回兜底结果

6.2 实操心得

第一条心得:工具结果一定要标注身份。模型分不清“这是我查到的”还是“用户告诉我的”,容易把工具输出当成对话内容理解。我在工具结果前加了前缀“工具返回:”,让模型有所区分。

第二条心得:System 提示词里一定要写明“任务完成必须调用 final_result”。这个约束比任何代码都有效,它直接在决策层面约束模型。没有这一句,模型可能聊着聊着就停了,不给结构化输出。

第三条心得:重试要有限度。校验失败重试一次就够了,最多两次。超过次数直接返回降级结果,避免把用户晾在那儿,也避免无限烧 token。

第四条心得:每增加一个新工具,都要跑一遍最小场景回归测试。工具描述写得再好,模型在复杂场景下也可能误用。提前准备好测试用例,比临时排查省心得多。

最后再说两句

我实实在在体会到的,是这三件套看着各自独立,实际上一环扣一环:工具描述影响模型决策,模型决策影响上下文走向,上下文走向又决定最终输出结构。任何一环偷懒,最后都在解析阶段给你上眼药。

建议你也照这个节奏:先手搓一个最小内核,把天气、提醒这类简单工具接进去,跑通闭环。代码丑一点没关系,关键是把链路理顺。之后再考虑上重型框架、接外部数据库、做长期记忆,都是水到渠成的事。

下一步我打算给 Pi 加上消息持久化和跨会话记忆,让它能把“用户上次聊到一半的事”自动续上。这个做完了再回来写新的总结。

RELATED READING

延伸阅读

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