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

发布时间:2026/10/19 11:41:42
Agent内核组装:Tool、Context与输出合约全解析
接上回。Pi 这个项目已经能和大模型对话了但说实话上一版充其量是个“聊天壳子”——模型很能说却什么都做不了。这一篇要干的事很明确把 Tool、Context 和输出合约这三块拼起来让 Pi 从“会聊天”变成“能办事”。这也是一般 Agent 内核最核心的骨架做完这一轮Pi 才算真正被“组装”出来。如果你也在从零搭 Agent被函数调用、上下文塞爆、输出解析搞到头大这篇的踩坑记录应该能帮你少走不少弯路。1. 组装前的整体思考为什么是这三件套1.1 Tool、Context、输出合约各自解决什么问题一个能帮用户干活的智能体光有模型肯定不够。模型本质上是一个“预测下一个词”的机器它擅长的是生成文本不是执行动作。要让 Pi 查天气、写提醒、调接口就必须给它一个外挂的“工具箱”也就是 Tool 层。但人干活不光要有工具还得知道上下文。用户十分钟前说过“明天我要去爬山”现在问“那还要不要带伞”Pi 如果只看当前这句话根本不知道“那”指的什么。所以 Context 层要负责把环境信息、历史对话、工具返回结果打包成模型能理解的消息序列。有了工具、有了上下文模型终于能输出结果了。可它输出的是自然语言口语化的、措辞飘忽的。下游程序要拿这个结果去创建提醒、写入数据库总不能靠正则硬抠。输出合约就是干这个的强制模型按约定好的结构化格式交付能校验、能兜底出错还能重试。打个比方这就跟外包助理干活一样你得给他电脑Tool、一份背景简报Context、一个报告模板输出合约他交回来的东西才能直接用。1.2 组装顺序与依赖关系这三层不是并列关系是有依赖链的。我的组装顺序是这样的先确认 LLM 接口支持函数调用Function Calling / Tool Use这是地基。搭好 Tool 注册中心把外部能力抽象成统一的工具描述。再写 Context 组装器把历史消息、用户输入、工具结果按顺序合并成 messages 数组。最后套输出合约把“模型最终回话”也定义成一个结构化动作保证下游能解析。为什么是这个顺序因为每一层都依赖下面一层。输出合约要管住的是“最终决策”而决策又来自上下文里的工具结果工具调用又得靠 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 是给模型看的不是给人看的。模型决定要不要调这个工具、传什么参数全靠这几行描述。很多时候模型“该调不调”不是模型傻是描述写得太含糊。配套的注册类也很简单数据结构上用字典存储执行时直接映射到 handlerclass 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(funknown 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_steps5): 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, toolsregistry.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_forecastdescription 也没区分清楚。结果模型有概率选错。后来干脆合并成一个工具用参数区分查询内容问题立刻消失。工具设计原则应该是能合并的合并能拆分的拆分每个工具都要有清晰的边界。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, dateNone): # 实际项目里这里去请求天气服务返回值转成摘要文本 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 也注册进去了。它没有真正的执行动作函数体就是返回传入的参数交给上层逻辑去处理最终结果。然后组装 Contextclass 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 加上消息持久化和跨会话记忆让它能把“用户上次聊到一半的事”自动续上。这个做完了再回来写新的总结。