上一篇文章里,我把 Pi 这个对话骨架跑通了,能聊,能接住用户的话,也能给个像模像样的回答。但距离“能用”还差一步:模型不知道什么时候该调用工具,工具返回的数据也没法稳定地喂回给模型,模型输出的内容更没有一份双方都认的格式约定。这其实就是标题里说的三件事:Tool、Context 和输出合约。
Pi 不是一个聊天玩具,它是我在本地组装的一个小型 Agent 原型,目标是通过大模型接口把“能听会说”升级成“能动手”。而这个“动手”能力,全靠 Tool 提供;工具怎么被正确调用,取决于 Context 怎么组装;工具执行完的原始结果能不能被用户读懂、被下游流程消费,又取决于输出合约。这三样东西单独拎出来每一样都不复杂,但真正难的是把它们焊成一个整体。这篇文章就是记录我组装这三件套的完整过程,每一步怎么设计、为什么这么设计、中间踩过哪些坑,都会讲清楚。如果你正在用大模型 API 做 Agent 或者自动化工作流,这篇应该能帮你少走不少弯路。
1. 为什么 Tool、Context 和输出合约必须一起设计
很多人一开始都会犯同一个错:先把工具函数写出来,然后让模型在对话里“自由发挥”,最后靠正则表达式去解析模型的回答。我在 Pi 上第一次这么做的时候,效果惨不忍睹——工具函数倒是能跑,但模型要么根本不知道该调它,要么给出的参数牛头不对马嘴,要么工具结果返回了模型也用不明白。
1.1 工具能跑,不等于模型能用
工具函数本身是给 Python 跑的,但模型只知道它的文本描述。模型看到的并不是你的函数代码,而是你在系统提示词或者工具定义里写出来的“说明书”。如果说明书写得含糊,或者参数 schema 设计得随随便便,模型就只能连蒙带猜。比如我早期给 Pi 写过一个获取会议室的工具,description 写的是“查询会议室空闲情况”,参数里有 start_time、end_time、room_id。看起来没问题,但模型在实际调用时经常只传一个 start_time,不传 end_time,或者把 room_id 传成一个房间名字符串而不是数字编号。
问题不在模型,而在我的工具定义没有告诉模型:这两个时间参数必须成对出现、room_id 具体有哪些可选值。模型是在做“补全”,它只能根据你提供的文字和结构去猜最合理的调用方式。工具定义不严谨,模型就会用它的想象力替你补漏。所以 Tool 的组装,第一步不是写函数,而是把函数翻译成模型能精确理解的那份“说明书”。
1.2 执行结果进不了 Context,工具等于白调
工具调用成功后,还有一个更隐蔽的坑:模型生成回答时根本看不到工具执行结果。大模型的上下文就是消息列表,如果你只是把工具函数跑完,拿返回值在 Python 里打印了一下,然后让模型继续生成回复,模型其实是在“盲答”。
我最早以为只要工具结果出现在终端里,模型自己就会知道。后来发现根本不是这么回事。模型能看到的全部内容都在 Context 里。工具结果必须重新写回消息列表,放在一个模型能读到的统一位置,而且要明确标注这是工具输出。否则会出现一种很滑稽的现象:模型明明说“好的,我帮你查一下”,然后工具也确实跑完了,但接下来模型开始自己瞎编数据——因为它根本不知道工具返回了什么。
这就是 Tool、Context、输出合约要一起设计的原因。Tool 决定“能做什么”,Context 决定“模型知道什么”,输出合约决定“回答长什么样”。三者是一条水流,任何一环断了,整条链路都瘫痪。
1.3 组装的第一原则:先定数据流,再写具体代码
我在推倒重来之后,给自己定了几条铁律,后面所有设计都围绕这些原则展开:
- 工具的 schema 描述必须出现在每次请求的上下文中,每次对话都带上,不能只在第一轮带。
- 工具执行结果必须以结构化的形式写回上下文,并明确区分“这是工具输出的 JSON”和“这是模型自己的思考”。
- 模型产出必须走输出合约,不仅约束最终回复的格式,连中间的工具调用参数也要校验。
- 一旦模型调用工具,就强制进入“模型→工具→模型”的循环,而不是让模型一次性同时干推理和回答。
这几条规则开始看着笨重,但实际跑起来后发现,它们把“不确定性”锁住了很大一部分。模型的不确定性永远在,但你可以通过设计把不确定性的影响范围压缩到一个可控的角落。后面每一步都是在填充这几条原则的细节。
2. Tool 层组装:从“模型看得懂”到“系统跑得通”
Tool 层是整个 Pi 的地基。这一步做得越扎实,后面 Context 和输出合约就越省心。我在这一阶段主要做了四件事:写一份合格的工具描述、在函数入口做参数强校验、设计结构化的错误返回、最后保持工具函数本身的无状态性。
2.1 工具描述:不是写给人看的功能说明,而是写给模型的调用手册
我后来把工具描述想象成“给一个新同事写的工单处理 SOP”。光说“处理工单”没用,要写清楚:什么条件下用这个工具、参数分别是什么意思、参数之间有什么约束、有没有默认值、什么情况下会报错。
以 Pi 里的一个简单工具为例,查询天气:
{ "type": "function", "function": { "name": "get_weather", "description": "查询指定城市在指定日期的天气情况。当用户提到城市和日期且需要天气信息时使用。如果用户没给日期,默认使用今天。", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市中文名,例如:北京、上海、广州。必须是中国城市的中文名称。" }, "date": { "type": "string", "description": "查询日期,格式为YYYY-MM-DD。如果用户没有指定日期,不要填写,由系统默认处理。" } }, "required": ["city"] } } }这里有几个细节值得细说。description里我明确写了“当用户提到城市和日期且需要天气信息时使用”,这是给模型的触发条件;如果不写,模型可能在闲聊时也强行调用工具。city的描述里写了“必须是中国城市的中文名称”,这是防止模型把“北京”翻译成 “Beijing” 塞进去。date的描述里写了“如果用户没有指定日期,不要填写,由系统默认处理”,这比在代码里处理缺失日期更有效——模型会把 missing 状态留给你,而不是自己脑补一个未来日期。每一个描述都不是废话,都是在降低模型犯错的概率。
2.2 参数强校验:入口处拦住非法调用
模型调用工具时返回的参数,本质上只是“一段符合 JSON 格式的文本”。你无法保证它一定符合你的 schema。我见过模型把数字参数写成字符串、把日期写成“明天”、把枚举值写成英文的情况。所以在工具函数入口,第一件事就是做强校验,而不是直接丢给函数体。
def safe_call_tool(name: str, arguments: dict) -> dict: schema = TOOL_SCHEMAS[name] try: validated = validate_arguments(schema, arguments) except ValidationError as e: return { "status": "error", "error_type": "invalid_arguments", "message": f"参数校验失败: {e}", "hint": "请根据参数说明重新生成合法的参数" } return execute_tool(name, validated)这里的validate_arguments你可以用任何 JSON Schema 校验库,或者直接用 Pydantic 定义强类型模型。关键是校验失败的返回值也有固定结构,方便后面模型自己根据错误信息修正参数重新调用。这一步做下来,工具函数内部就永远不会碰到“参数类型不对”的崩溃,所有异常都被挡在入口之外。
2.3 工具报错:让模型去解释错误,好过硬编码兜底文案
工具执行时还可能抛异常,比如网络不通、服务返回 500、数据不存在。我之前习惯在工具里 try-except 然后直接返回一句“查询失败,请稍后再试”。后来发现这样很糟——模型根本不知道发生了什么,用户问“为什么查不了”,模型只能继续编。
更好的做法是把错误结构化返回,并且让模型看到错误原因:
{ "status": "error", "error_type": "external_api_error", "message": "天气服务连接超时", "hint": "建议让用户稍后再试,或者询问是否切换查询昨天/明天的天气" }把这段 JSON 写回上下文后,模型生成回复时就会参考hint来组织语言,告诉用户具体的失败原因和替代方案。这比任何预先写死的提示词都自然,而且每个工具的错误处理逻辑可以共用一套模式。核心思路是:错误信息也是给模型看的上下文,不是给用户看的最终文案。最终文案让模型去组织,但组织的方向和边界由 hint 来约束。
2.4 工具函数设计的三个心得
工具函数本身不要写复杂业务逻辑,职责越单一越好。比如“获取工单列表”和“获取工单详情”就拆成两个工具,不要写成一个工具靠参数区分。原因有二:一是模型如果只看到一个大而全的工具,它可能搞不清该传什么参数组合;二是工具拆细之后,描述更精确,模型调用成功率明显上升。
还有,工具函数的参数类型能少用array就少用。模型在生成数组参数时,经常把类型搞错,比如把["a","b"]写成"a,b"。如果非要数组,description 里一定要给一个明确的格式示例。我自己的经验是:能用 string 解决的绝不用 array,能用单个对象的绝不用嵌套数组。
最后,所有的工具函数都做成无状态的,执行结果完全由输入决定。这不仅是工程上的好习惯,对模型也更友好——对于一个无状态的工具,模型不需要去记忆“上一次调用后发生了什么变化”,每次调用都是一次干净的查询或操作。
3. Context 组装:给模型一张不会被弄乱的工作台
Context 就是模型的工作台。它上面摆着系统提示词、历史对话、工具描述、工具结果,所有模型能“看到”的信息都必须在这里。组装 Context 的难点不是把东西塞进去,而是要让模型清楚分辨每一块信息的角色和边界,同时控制住上下文长度。
3.1 系统提示词:除了人设,还要有环境变量和运行时信息
我早先的系统提示词就是一段人设:“你是 Pi,一个智能助手……”后来发现不够。模型需要知道大量运行时信息才能正确决策。比如:
- 当前真实日期和时间(模型不主动知道“今天”是哪天)。
- 当前环境有什么工具可用(即便工具列表在接口参数里传了,系统提示里再列一版摘要,模型调用效果往往更好)。
- 运行模式是“工具编排模式”还是“纯对话模式”。
- 输出必须遵循的合约要求,包括不允许输出的内容类型。
我把这些信息做成了一个render_system_prompt()函数,动态拼进系统提示词里。日期用代码实时生成,确保每次请求都是最新的;工具摘要也从工具注册表里自动生成,避免手写两份不一致的清单。
拼完之后的系统提示词大致长这样:
你是 Pi,一个本地搭建的智能助手,基于大模型接口运行。 当前日期:2025-04-18 可用工具:get_weather(查询天气)、get_meeting_room(查询会议室空闲情况)、create_todo(创建待办事项)。 使用规则: 1. 当用户需求涉及可用工具功能时,必须调用工具,不要直接编造答案。 2. 调用工具后,必须等待工具结果返回,再基于结果组织最终回复。 3. 最终回复必须以符合输出合约的 JSON 格式输出,合约定义见下文。模型读完这一屏内容后,相当于拿到了一份“开机指南”,在错误的方向上狂奔的概率会大幅下降。
3.2 消息列表的正确顺序:用户消息、工具结果、系统边界的摆放
Context 组装的核心是消息列表。我采用的标准结构是:
messages = [ {"role": "system", "content": system_prompt}, *conversation_history, # 历史对话 {"role": "user", "content": current_user_input}, ]模型第一次给出工具调用后,要把工具结果作为一个新的消息追加进去,然后再交给模型生成最终回复。这一步乍一看简单,但里面有个魔鬼细节:工具结果必须放在一个明确的位置,不能和历史对话混在一起。
Pi 的做法是:每轮工具调用完成后,往消息列表追加两条消息。第一条是assistant角色,内容是一个简短的“我将调用工具 get_weather”,第二条是tool角色,内容是工具执行的原始 JSON 结果。这里的顺序必须是“模型声明调用工具→工具返回结果”,模型才能在这个基础上继续推理。
messages += [ {"role": "assistant", "content": f"我需要调用 {tool_name} 来获取信息。"}, {"role": "tool", "content": tool_result_json}, ] # 然后再让模型基于现有上下文生成最终回复 final_response = model.chat(messages)如果跳过第一步的 assistant 声明,直接把工具结果塞进上下文,有些模型会分不清这段 JSON 是谁说的,从而把工具结果误认为用户输入。我踩过这个坑,后来拆成两条消息就稳定多了。这个顺序本质上是在模仿人类协作的对话结构:A 说“我去查一下”,B 递过来结果,A 再综合信息给结论。模型是在做模式复现,结构越接近它的训练语料,表现就越自然。
3.3 上下文长度与截断策略:不要无脑堆历史,保住关键信息
Context 是所有信息的载体,但长度是有限的。对话一长,历史消息就会把 token 撑满,工具结果和系统提示被挤到最后,模型容易“忘掉”前面的内容。Pi 里我采用的策略是“优先级分层”:系统提示词和工具结果永远保留,历史对话按权重截断。
询问类工具的即时结果必须留下,因为模型还要基于它生成回复;历史寒暄可以压成摘要;“用户上上轮问了很多但和当前任务无关的内容”直接丢弃。我在实现时做了一个简单的滑动窗口:
- 系统提示词:固定保留。
- 最近 3 轮完整对话:完整保留。
- 更早的历史:压缩成一段 200 字以内的摘要,由模型通过一次单独的 summarize 调用生成。
- 当前工具结果与相关工具调用声明:固定保留。
“摘要历史”这步单独会消耗一次模型调用,但能显著提升长会话场景下的效果。如果你的场景不做非常长的多轮对话,可以先把这步省掉,只在 token 超限时触发。
3.4 一个容易忽略的坑:旧工具结果污染新对话
组装 Context 时最隐蔽的 bug 是过期工具结果残留。比如用户第一轮问“北京天气”,工具返回了数据,模型正常回答了。第二轮用户问“那上海呢?”如果你的消息列表里还堆着第一轮的天气 JSON,模型可能会犯糊涂,把北京的数据和上海的问题混在一起,或者干脆拿旧结果硬答。
我加了一条铁律:每次用户发起新一轮对话时,如果上一轮的工具结果已经用于生成最终回复,就把它从 Context 中移出,只保留最终回答作为历史。只有当前正在处理的那一轮工具结果才保留在上下文中。这个规则简单粗暴,但非常有效。
def build_messages(new_user_input: str) -> list: messages = [{"role": "system", "content": system_prompt}] for item in cleaned_history: # 已剔除已消费的工具结果 messages.append(item) messages.append({"role": "user", "content": new_user_input}) return messages配合这个清理逻辑,我还会在日志里记录每次组装的 Context 快照,出现问题可以直接回溯是哪一条旧消息影响了模型。
4. 输出合约:让模型按格式说话,用代码兜底校验
上下文组装完之后,模型的输出就成了唯一的“产品”。如果模型输出的是一堆散文,下游没法分析,用户也拿不到结构化结果。输出合约就是在这里立规矩的,它是一份模型必须遵循的输出 JSON 格式约束。
4.1 输出合约定义:终极回复的结构化模板
Pi 的合约里定义了一个统一的外层结构:
{ "fulfillment": { "speech": "直接面向用户的话", "data": {}, "intent": "系统识别到的用户意图分类" "errors": [] } }speech是模型真正要展示给用户的那句话。data是结构化数据,比如查到的天气数据、会议室列表,供前端渲染或下游流程使用。intent是意图分类,例如weather_query、meeting_room_booking、general_chat。errors用于存放错误信息,正常回答时为空数组。
这个结构看着直白,但好处很大。模型被要求把所有思考成果都装进这层合约,用户看到的speech、系统消费的data、逻辑分支用的intent就被自然分开了。不会出现“模型的解释文本里夹着一份 JSON 数据”这种脏局面。
4.2 JSON Schema 约束:让模型直接在采样阶段收敛格式
现在的模型服务商基本都支持响应格式强约束,通过 JSON Schema 锁定输出结构。严格语法校验模式下,模型在生成 JSON 时每一步采样都会做语法检查,从根上避免了“JSON 截断”“逗号缺失”这类低级错误。
Pi 里用的 schema 大致长这样:
{ "name": "fulfillment", "strict": true, "schema": { "type": "object", "properties": { "fulfillment": { "type": "object", "properties": { "speech": {"type": "string"}, "data": {"type": "object"}, "intent": {"type": "string", "enum": ["weather_query", "meeting_room_booking", "create_todo", "general_chat"]}, "errors": {"type": "array", "items": {"type": "string"}} }, "required": ["speech", "data", "intent", "errors"], "additionalProperties": false } }, "required": ["fulfillment"], "additionalProperties": false } }strict模式加上additionalProperties: false,效果就是把模型的输出锁在一个严格的 JSON 容器里。模型想输出合约之外的内容,在采样阶段就会被拒掉。这一步直接改变了模型生成结果的行为模式,比事后解析 JSON 再纠错要稳一个量级。
4.3 少样本示例:schema 管格式,示例管语义
光有 schema 还是不够。schema 只约束了“字段的类型和结构”,但没有约束“字段应该填什么粒度的内容”。比如speech字段,模型知道必须是字符串,但不知道应该填 10 字以内的简洁回复,还是填一段 200 字的话术。这时候少样本示例就很有用了。
我在输出合约的描述旁边,附带了一个示例输出:
{ "fulfillment": { "speech": "北京今天晴,气温 18-28 度,东南风 3 级,适合出门。", "data": {"city": "北京", "weather": "晴", "temp_min": 18, "temp_max": 28}, "intent": "weather_query", "errors": [] } }模型看到这个示例后,就知道speech应该用自然的人话,data里放结构化数值,intent分层不能乱写。尤其是data字段里放什么粒度的信息,必须有示例。我试过不给示例,模型会把整个回答文本复制进data,导致下游解析很痛苦。
4.4 校验与重试机制:不能把宝全押在模型身上
即使 schema 强制了格式,模型仍然可能输出语义上不符合合约的内容。比如errors数组为空,但speech里却告诉用户“出错了”。这属于语义层面的违约,语法校验查不出来。所以输出合约的最后一道防线是代码层校验:
def validate_and_repair(fulfillment: dict) -> dict: try: FulfillmentModel.model_validate(fulfillment) return fulfillment except ValidationError: # 把校验错误塞回上下文,让模型自己修复 repair_prompt = f"你的输出不满足要求:{e},请修复后重新输出。" messages.append({"role": "user", "content": repair_prompt}) return None # 触发重试这里的逻辑是:第一次输出不通过校验时,不直接报错,而是把校验失败的详细信息作为一条新消息追加到上下文,让模型基于错误自己修正。实践证明,模型看到具体的校验错误后,第二次修正的成功率很高。如果连续两次失败,再落回拒绝回答的兜底分支。
这样设计的原因很简单:让模型自己修,比你在外面硬改它的输出更可靠。你硬改 JSON 字段,改出来的结果模型自己都不知道,后面的对话逻辑会乱套。给模型一次“自我纠错”的机会,往往代价很低,收益却不小。
4.5 输出合约的边界:字段命名要对模型友好
输出合约的字段名,最好和模型平时见到的自然语言表达一致。比如speech比utterance更好,errors比faults更好。不是你对英文不熟,而是模型在训练语料里更常遇到speech这类词,它理解起来更轻松,生成时也更顺。同理,intent的枚举值也要选模型容易分类的词,不要在weather_query和weather_inquiry之间摇摆不定,选一个就行。
如果业务里需要非常复杂的输出结构,我的建议是分步输出:先让模型输出核心字段,再通过一次工具调用把扩展字段补全,而不是一次性要求模型输出一个巨型 JSON。模型生成的 JSON 越长,准确率越低。短小聚焦的合约,效果远好于大而全的合约。
5. 三件套闭环:把 Tool、Context、输出合约焊成一条完整的链路
三件套分开讲完之后,最关键的一步是组装闭环。Tool 是脚,Context 是身体,输出合约是嘴,单靠任何一部分都跑不起来。这一节就是 Pi 完整处理“一个用户请求”时的数据流全貌,以及我为了让它稳定运行加的各种保险丝。
5.1 一次完整调用的八步链路
Pi 的主循环这么走:
- 接收用户输入,调用
build_messages()生成初始消息列表,系统提示词、清理后的历史、当前用户消息全部就位。 - 把消息列表连同工具 schema 列表、输出合约一起提交给模型服务接口。
- 模型返回两种可能之一:直接生成满足输出合约的最终回复,或返回一个工具调用请求。
- 如果是工具调用请求,先校验工具名是否存在、参数是否符合 schema,失败就返回结构化错误。
- 参数合法则执行真实工具函数,拿到工具的原始 JSON 结果。
- 把“模型声明调用工具”的消息和“工具结果”的消息追加进消息列表,再回到第 2 步。
- 模型基于工具结果生成最终回复,进入输出合约校验。
- 校验通过后,把
speech返回给用户,把data交给下游,把整轮消息归档进历史。
这个循环很朴素,但每一步都踩在前面设计好的轨道上。工具调用出错时,走的是“错误结果写回上下文→模型自纠错→重试”的路径,而不是直接把异常抛给用户。输出合约不通过时,走的是“校验错误回填→模型重新生成”的路径,而不是把半成品 JSON 硬拼成回复。
5.2 循环熔断:防止模型在工具调用里死循环
模型在工具调用环节有一个典型毛病:它会连续多次返回相同的工具调用请求,尤其在工具报错后。比如第一次调用天气工具失败,返回了错误 JSON,模型看到后非但不修正,反而再生成一次一模一样的调用,然后又失败,无限循环。
我加了两道保险:
- 最大调用次数限制:单轮用户请求中,模型最多调用工具 4 次。超过之后直接放弃工具路径,强制生成一个带错误的最终回复。
- 重复调用检测:如果模型连续两次请求同一个工具且参数完全相同,就判定为“工具调用卡死”,中断循环并提示它换一种方式。
这个熔断机制非常必要。没有这两个限制前,Pi 曾出现过一次“调用会议室查询工具 9 次”的恐怖经历,因为工具返回不可用,模型就一直尝试,白白烧了大量 token。锁住循环次数之后,这类问题再没有出现过。
5.3 可观测性:没有完整日志,组装就是盲人摸象
组装三件套的调试期,日志是最重要的工具。我每轮请求都会记录以下信息:
- Context 快照:最终发给模型的消息列表全文。
- Token 消耗:输入、输出、单轮总计。
- 模型返回原文:包括工具调用请求和最终回复,未经过任何后处理。
- 校验结果:参数校验是否通过、输出合约校验是否通过。
- 工具执行耗时和结果摘要。
有一次 Pi 回答驴唇不对马嘴,我查了半天代码没找到问题,最后翻日志发现上上轮的工具结果没有被清理,混进了当前轮次的上下文。如果没有 Context 快照日志,这个问题可能永远排查不出来。日志要记到“可重放”的程度——能根据日志完整还原当时的模型输入和输出,调试效率直接翻倍。
5.4 边界情况处理:工具不存在、结果超长、并行调用
还有几个边界情况,我在组装时做了特殊处理。
模型偶尔会“编造”一个根本不存在的工具名。这种情况直接返回结构化错误,提示“该工具不存在,以下是可用工具列表”,让模型重新选择。
工具返回结果可能非常长,比如查询出来 500 条工单记录。把完整结果写回上下文既费 token 又干扰模型注意力。我做了个截断策略:超出 2000 字符的部分直接截断,并在结果末尾追加一行“结果过长,已截断,仅展示前 N 条”。模型如果需要更多数据,会主动提出分页查询或缩小条件。
多个工具并行调用的情况,Pi 暂时不做。一次只允许模型返回一个工具调用请求。并行调用虽然能省时间,但会让 Context 的组装复杂度大幅上升,而且不同工具结果之间可能会互相干扰。从稳定性和可维护性角度看,串行调用是性价比最高的选择。等日志追踪和错误恢复机制更成熟之后,再考虑并行化。
组装到这步,Pi 才算真正跑通了“用大模型做 Agent”的第一版闭环。后面要做的事情还有很多,但这三块地基已经焊在一起,不再是一堆散装的零件。
我在实际使用中的一个体会是:这三件事最好从第一天就捆在一起考虑。分开做的话,你很快会陷入“工具能跑但模型不用,模型用了但回答没法解析,回答能解析但数据进不了下一步”的连环坑。先把数据流的形状定清楚,再去填每一块的细节,整个系统才能真正转起来。