本章目标:搞懂 Agent 与模型交互的全部接口细节------消息格式、token、采样参数、 流式、结构化输出、function calling;再学会「设计一个好工具」, 这是 Agent 能力边界的关键。
1. 聊天补全( Chat Completion)接口
几乎所有的现代 LLM 都暴露一个 Chat Completion 接口,它把「一段对话」作为输入, 返回「下一个 assistant 消息」作为输出。Agent 循环就建立在这个接口之上。
1.1 消息格式
一次请求由 messages 数组组成,每条消息有 role 和 content:
| role | 含义 | 典型来源 |
|---|---|---|
system |
系统指令,设定模型行为 | 开发者注入,不可信?* |
user |
用户输入 | 终端用户 |
assistant |
模型的回答 | 上一轮模型输出 |
tool |
工具执行结果 | 程序执行工具后回填 |
一个深刻的坑 :
system消息与user消息在安全上的地位不同。system通常被模型视为「最高优先级指令」,因此注入到user的恶意指令 往往压不过system里的约束 ------这是防提示注入的机制基础(第 11 章)。 但也有些模型对system与user的区分没那么严格,不能完全依赖。
1.2 token 与上下文窗口
- token:模型的最小处理单元。1 个中文 ≈ 0.6--1 token,1 个英文单词 ≈ 1.3 token, 代码里一个符号常常就是 1 token。不同模型 tokenizer 不同。
- 上下文窗口:模型单次能「看见」的最大 token 数。 2024 年的主流是 8k--128k,2025--2026 年长上下文(200k--1M)逐渐普及。
- 输入与输出分开计费:输入 prompt tokens 便宜,输出 completion tokens 贵。
工程启示:
- 上下文窗口是硬约束,Agent 的上下文管理(第 04、05 章)就是围绕它设计的。
- 把「不重要的历史」放进工具返回而不是全塞 prompt,是省 token 的核心思路。
- 「长上下文 ≠ 好效果」:模型对长上下文中中段的信息利用率远低于开头和结尾, 即所谓「lost in the middle」。所以关键信息要放在开头(system)或结尾(最近消息)。 ------如何「按注意力曲线排消息、做预算、用缓存」,是第 05 章上下文工程的主题。
mini-agent 的
TokenEstimator(src/util/tokens.ts)用「中文 1 字符≈1 token、 英文 4 字符≈1 token」的启发式估算,够用于预算管理,但不精确。 生产环境应使用模型官方的 tokenizer(如 tiktoken)。
1.3 Prompt Caching(提示缓存)
多轮 Agent 循环里,系统提示、工具定义和历史前缀是高度重复 的。 Prompt Caching 让相同前缀复用 KV 缓存,不用重复计算------是成本工程里最大的单一杠杆之一:
| 维度 | 无缓存 | 有缓存 |
|---|---|---|
| 每轮前缀计算 | 每次都重新算 | 只算一次,后续命中缓存 |
| 多轮 Agent 输入成本 | 随轮数线性增长 | 增长大幅放缓 |
| 缓存命中 token 单价 | --- | 明显更低(Anthropic 约 0.1×、OpenAI 约 0.5×,以计价为准) |
缓存友好的设计原则(详细推导见第 05 章 §4):
- 稳定内容放前、变化内容放后------system 与工具定义在前,用户输入与检索结果在后;
- 保持前缀字节一致------别把动态时间/随机数拼进 system 开头,那会每次击碎缓存;
- 工具定义排序稳定 ------按名字排序,保证请求间字节一致 (mini-agent 的
ToolRegistry.toDefinitions()正是如此); - 区分「稳定段」与「动态段」------别让记忆检索结果混进前缀。
text
缓存友好 缓存不友好
system(稳定) ──► 缓存 system(含动态时间) ──► 每次击碎
工具定义(稳定) ──► 缓存 检索记忆(每次不同) ──► 拼进前缀,击碎
用户输入(变化) 用户输入
工具结果(变化)
对多 Agent / 多轮任务,Prompt Caching 往往是「比换模型更划算」的优化。 配合动态工具子集(第 05 章 §5)一起做,成本下降最显著。
1.4 采样参数
| 参数 | 作用 | 建议 |
|---|---|---|
temperature |
随机性。0=贪心,越大越多样 | 代码/数据任务 0--0.3;创意任务 0.7--1.0 |
top_p |
核采样,只从累积概率前 p 的 token 里选 | 与 temperature 二选一调整 |
max_tokens |
输出长度上限 | 必须设置,防止无限输出 |
stop |
遇到这些序列即停止生成 | 对结构化输出有用 |
seed |
固定随机种子(部分模型) | 用于复现测试 |
对 Agent 的关键建议 :Agent 的「决策型」调用(该调用哪个工具、该怎么规划) 要用低 temperature(0--0.3),提高确定性。创意生成才用高温。 一个常见的工程失误是全程 0.7,导致 Agent 决策摇摆不定。
1.5 多模态输入:Agent 不只是「读文字」
2024--2026 年的主流模型普遍支持多模态输入,Agent 也因此在读取真实世界这件事上 扩展了能力边界------截图、图表、表格、音频、视频都可以成为感知输入:
| 输入类型 | 典型场景 | 关键工程点 |
|---|---|---|
| 图像 | 看 UI 截图、识别表格/发票、读图表 | 图像编码(base64 / URL)、分辨率与 token 权衡 |
| 音频 | 语音指令、会议记录 | 转写(ASR)通常在模型之外完成 |
| 视频/文档 | 视频片段、PDF/PPT | 通常先抽帧/转文本,再按文本处理 |
消息格式上的落点 :多模态内容在 Message.content 里表示为 内容片段数组 (ContentPart[]),而不是一个纯字符串。mini-agent 的 types.ts 已为 ContentPart 预留了 type: 'text' | 'image':
ts
interface ContentPart {
type: 'text' | 'image';
text?: string;
mediaType?: string; // e.g. 'image/png'
data?: string; // base64 编码的图像
}
三个工程原则:
- token 预算是硬约束:一张高分辨率图片可能相当于数百到上千 token。 生产系统要按任务需要压缩图片(降分辨率、转灰度),而不是原图直塞。
- 「先转文本」仍是主流降本手段:OCR / ASR 把图像与音频转成文本,让 小模型也能处理多模态内容;只在「需要看空间关系/视觉细节」时才走真多模态。
- 工具返回也可以是多模态:工具可以返回图片(截图、图表),模型据此 分析------这比「让工具返回一段文字描述」信息密度更高。这是多模态 Agent 的进阶形态(第 13 章 §2.2)。
教学提醒:mini-agent 的
MockProvider只处理文本,多模态是「预留接口」。 真实接入时,把ContentPart[]序列化成目标模型的多模态格式即可,Message这一层契约不需要改------这正是统一中间格式的回报。
2. 流式输出(Streaming)
LLM 逐 token 生成,流式接口把 token 逐个推送,而不是等全部完成。 流式对 Agent 有两个价值:
- 用户体验:第一 token 的延迟(首 token 延迟)从几秒降到几百毫秒, 打字机效果让用户觉得系统「在动」。
- 任务进度:在长任务里,流式输出让外部(用户/监视器)能感知进度, 也允许「提前中断」。
SSE(Server-Sent Events)是主流流式协议,响应体形如:
css
data: {"choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}
data: {"choices":[{"delta":{"content":"今天"}}]}
data: {"choices":[{"delta":{"content":"天气"}}]}
data: {"choices":[{"delta":{},"finish_reason":"stop"}]}
data: [DONE]
工具调用的流式 :当模型决定调用工具,工具参数也是增量 推送的 (tool_calls 的 arguments 是逐步拼接的 JSON 字符串)。客户端要:
- 累积每个
tool_call的index、id、name、arguments; - 等流结束时
JSON.parse拼接好的 arguments; - 若解析失败(截断),记录原始串,让工具校验层兜底。
mini-agent 的
OpenAIProvider.stream()(src/provider/openai.ts)完整实现了 SSE 解析 + 工具调用增量累积,是标准写法的教学版。
3. 结构化输出(Structured Output)
Agent 的循环里,模型经常要输出程序能直接用的结构:
- 规划器要输出步骤数组;
- 分类器要输出
{"label": "...", "confidence": 0.9}; - 评测器要输出打分。
三种实现方式(可靠性递增):
| 方式 | 原理 | 可靠性 |
|---|---|---|
| 提示 + 解析 | 提示「只输出 JSON」,程序用正则/JSON.parse 提取 | 低,模型可能加解释文字 |
| JSON 模式(JSON mode) | 模型承诺输出合法 JSON | 中,结构由提示保证 |
| 约束解码(Structured Outputs) | 后端把输出强制约束到给定 schema | 高,合法且符合 schema |
JSON Schema 驱动的结构化输出 是 2024--2026 年的标准做法: 你提供一个 JSON Schema,模型只输出符合该 schema 的对象, 且类型安全(数字就是数字,不会变成字符串)。
工程启示:
- 结构化输出是 Agent 的「内部 API」,用于 Agent 各模块之间传递数据。
- 解析失败必须兜底:默认值 + 重试一次 + 日志。
- 尽量用「约束解码」而不是「提示+解析」,除非模型不支持。
mini-agent 的
Planner就演示了「提示 + 解析 + 兜底」的三级策略 (src/planner.ts):先让模型输出 JSON 数组,容忍 ```json 代码块包裹, 解析失败自动退化为启发式切句。
3.1 JSON mode / response_format / json_schema:别混为一谈
各家 API 的「结构化输出」参数长得像,但语义不同。做一个澄清(以 OpenAI 风格为例, 各家命名不同,思想一致):
| 参数 | 作用 | 保证 | 局限 |
|---|---|---|---|
response_format: {type:"text"} |
默认 | 只保证合法文本 | 不保证是 JSON |
response_format: {type:"json_object"} |
JSON mode | 输出是合法 JSON 对象(不是数组) | 不保证符合你的 schema;必须把「输出 JSON」写进提示 |
response_format: {type:"json_schema", json_schema:{...}} |
Structured Outputs | 输出符合给定 schema 的对象 | 更贵;部分模型不支持 |
| 提示 + 解析 | 无约束 | 无 | 模型可能加解释文字 |
关键区分:
- JSON mode ≠ 符合 schema 。
json_object只保证「能 parse」,不保证字段对; 它常要求提示里显式声明「输出 JSON」,否则可能报错; - Structured Outputs 才是 schema 级保证------类型安全、字段齐全、 枚举合法,是规划器/评测器这类「内部 API」的正确选择;
- 流式下的 json_schema:部分实现仍然逐 token 流式输出(只是保证最终合法), 不能假设「流式 + schema = 每个增量片段都合法」;
- 成本:约束解码通常不显著增加 token,但会增加服务端计算;用前对比价格表。
工程决策树 :只要程序要
JSON.parse模型的输出,就优先用「约束解码」 (json_schema);退而求其次是 JSON mode;「提示+解析」只作为最后兜底。 无论用哪种,解析失败都要有兜底(重试/默认值/退化),因为没有任何 机制能 100% 保证在流式截断、服务端降级等异常下输出合法。
4. Function Calling(工具调用)
4.1 它是什么
Function Calling 让模型输出「调用哪个函数、传什么参数」的结构化请求, 而不是只能输出文本。这是 Agent 的行动接口,2023 年 OpenAI 引入后成为行业标准, Anthropic、Google、Mistral、Meta 等都实现了各自的变体。
一次 function calling 的请求长这样(OpenAI 风格):
jsonc
// 请求:告诉模型有哪些工具可用
{
"model": "gpt-4o-mini",
"messages": [
{"role": "system", "content": "你是客服助手,可以查询订单。"},
{"role": "user", "content": "我的耳机到哪了?"}
],
"tools": [
{
"type": "function",
"function": {
"name": "query_order",
"description": "按订单号查询订单物流状态",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "订单号"}
},
"required": ["order_id"]
}
}
}
],
"tool_choice": "auto"
}
模型的响应:
jsonc
{
"choices": [{
"message": {
"content": "让我查一下。",
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "query_order",
"arguments": "{\"order_id\":\"8847-223\"}"
}
}
]
},
"finish_reason": "tool_calls"
}]
}
程序端拿到 tool_calls,执行工具,然后把结果作为 role: 'tool' 消息回喂:
jsonc
{"role": "tool", "tool_call_id": "call_abc123", "content": "已发货,预计明天到达,承运商:顺丰"}
于是循环继续。tool_call_id 是契约:必须把结果挂到对应的调用 ID 上, 模型才能把它与请求关联起来。
4.2 关键概念
| 概念 | 说明 |
|---|---|
tools |
模型可见的工具清单(名称+描述+入参 schema) |
tool_choice |
auto(模型自己决定)/ none(禁用)/ required(必须调用)/ 指定工具 |
tool_calls |
模型输出的调用请求数组(可能多个=并行) |
tool_call_id |
调用 ID,结果回喂时用于配对 |
finish_reason: tool_calls |
表示「这轮输出是要调用工具」而非结束 |
arguments |
字符串形式的 JSON 参数,需要解析 |
4.3 各家模型的差异
虽然大方向一致,但各家实现有细微差异,做多模型兼容时要注意:
| 维度 | OpenAI | Anthropic | Google Gemini | 本地/开源 |
|---|---|---|---|---|
| 工具调用字段 | tool_calls |
tool_use |
functionCall |
各不同 |
| 参数格式 | 字符串 JSON | 结构化对象 | 结构化对象 | 各不同 |
| 工具定义 | tools[].function |
tools[].input_schema |
tools[].function_declarations |
各不同 |
| 流式 | arguments 增量拼接 | input_json 增量 | functionCall 增量 | 各不同 |
工程启示 :你的 Agent 核心逻辑不应该绑定任何一家的格式 。 正确的做法是定义自己的 ToolCall 中间格式(如 mini-agent 的 types.ts), 用 provider 适配器在边界处转换。这就是 ChatProvider 抽象的价值 (src/provider/openai.ts 就是 OpenAI 风格适配器)。
4.4 tool_choice:精确控制「什么时候调、调哪个」
tool_choice 控制模型对工具的选择行为,是 Agent 循环里最被低估的控制杆之一:
| 取值 | 行为 | 适用 |
|---|---|---|
"auto"(默认) |
模型自主决定是否调用、调用哪个 | 绝大多数场景 |
"none" |
禁止调用任何工具,只输出文本 | 纯聊天/问答、检索结果已足够 |
"required" |
必须调用至少一个工具 | 强制走工具流程(如「查询必须经过数据库」) |
{"type":"function","function":{"name":"xxx"}} |
强制调用指定工具 | 定向任务(路由到特定工具) |
工程启示:
required是「防呆」的好工具 。当业务要求「查询必须经过数据库」时, 把tool_choice: "required"设为硬约束,而不是依赖模型的自觉------它把 「模型可能会偷懒直接编答案」这条风险从概率问题变成不可能。- 指定工具用于「路由」。在 Agent 循环外先确定「这一步该用哪个工具」, 再强制调用,能显著提高确定性(第 02 章 §5.2 路由模式的协议层实现)。
auto的行为随模型与工具描述变化 。同一个模型,工具描述写得好时auto更可靠;描述模糊时模型会「宁可不用」。调auto前先优化工具描述(§5)。- 注意各家差异 :部分模型把
tool_choice: "none"与「不传 tools」等同; 部分开源模型对required支持不完整。做多模型兼容时,tool_choice要按 provider 适配,不能假设各家语义一致。
4.5 并行与嵌套:工具调用的两种进阶形态
并行调用(Parallel Tool Calls) ------模型在一条消息里发出多个 tool_calls, 程序可以同时执行。这是多工具任务降低延迟的关键:
css
用户:「查一下北京和上海的天气,顺便把我上个月的订单记录也调出来」
模型:tool_calls = [weather(北京), weather(上海), query_orders(上个月)]
程序:Promise.all 并行执行 → 一次回喂 → 模型一次汇总
要点:
- 并行只适用于互相独立的工具调用;工具 B 依赖工具 A 的结果时必须串行;
- 并行过多会挤占输出 token 与工具侧并发资源,建议限制单轮并行数(如 ≤ 8);
- 结果回喂的顺序要与
tool_calls的顺序一致------mini-agent 用Promise.all保证结果数组顺序与输入一致(第 07 章 §9.2)。
嵌套调用(Nested/Chained Tool Calls) ------工具 A 的结果触发工具 B。 嵌套不能在一条消息里完成,必须分两轮(ReAct 循环的自然形态):
css
第 1 轮:模型调用 search_order(order_id) → 返回「订单在仓库 W12」
第 2 轮:模型看到结果,调用 get_inventory(W12) → 返回库存 → 组织最终回答
要点:
- 嵌套不需要特殊机制,它就是「把上一轮的工具结果作为本轮决策输入」;
- 让工具返回结构化、含关键关联信息的内容 ,能减少不必要的嵌套轮数 (如果
search_order直接返回了库存,就不需要第二轮,省一次模型往返)。
工具调用深度的上限 :一次 Agent 运行的模型往返次数 ≈ 首轮 + 每轮工具调用。 mini-agent 的 maxIterations(默认 8)就是给这个深度设的天花板------它是 防止「模型无限嵌套调用工具烧钱」的最后一道闸(第 02 章 §6.1)。
4.6 完整生命周期:从「模型想调工具」到「模型用上结果」
把前面所有概念串成一个完整的工具调用时序(OpenAI 风格,含流式):
css
用户: "帮我算一下 (2+3)*4"
│
① 请求 ──► POST /chat/completions
│ messages=[system, user], tools=[calculator]
② 流式 ◄── data: {delta:{content:"让我算一下"}}
│ data: {delta:{tool_calls:[{index:0,name:"calculator",arguments:""}]}}
│ data: {delta:{tool_calls:[{index:0,arguments:"{\"expr"}]}}
│ data: {delta:{tool_calls:[{index:0,arguments:"ession\":\"(2+3)*4\"}"}]}}
│ data: {delta:{},finish_reason:"tool_calls"}
③ 拼接 ──► arguments 累积 → JSON.parse → {expression:"(2+3)*4"}
④ 执行 ──► calculator.execute({expression:"(2+3)*4"}) → 20
⑤ 回喂 ──► 追加 {role:"tool", tool_call_id:"call_1", content:"20"}
⑥ 再请求 ──► POST /chat/completions(带上全部历史 + 工具结果)
⑦ 最终 ◄── "结果是 20。"(finish_reason:"stop")
这 7 步就是第 02 章「感知-思考-行动-观察」循环在协议层的一次完整往返。 工程上要特别注意 ② 流式:工具参数是逐字节增量 推送的,必须按 index 累积、在流结束时再 JSON.parse;解析失败要兜底(§2)。③→⑤ 是 ToolRegistry.call() 的职责(校验 + 执行 + 结果回填,第 07 章)。
4.7 function calling 的边界与工程坑
Function Calling 强大,但不是魔法。以下边界条件在真实系统里几乎都会遇到:
| 坑 | 症状 | 对策 |
|---|---|---|
| 单次工具调用数上限 | 模型想并行调 10 个工具,API 只返回前 N 个 | 工具本身「粗粒度」;限制单轮并行数(如 ≤8);告诉模型「一次最多调 3 个」 |
| 参数被截断 | 长 arguments 被 API/网络截断,JSON.parse 失败 |
流式累积后兜底重试;参数设计为「引用而非拷贝」(传 ID 不传全文) |
| 工具选择偏差 | 模型偏爱列表里靠前/描述长的工具 | 描述公平(等长);必要时把「互斥工具」合并;用评测校准 |
| 幻觉工具名 | 模型调用了不存在的工具 | ToolRegistry.call 对未知工具返回 isError(第 07 章),模型看到后自愈 |
| required 与可选字段混淆 | 模型漏传必填参数 | schema required 写全;校验失败信息明确列出缺哪个字段 |
| 空 arguments | 模型发出了工具调用但没给参数 | 视情况允许「无参工具」;有参工具走校验兜底 |
| 循环调用同一工具 | 模型反复调同一工具同一参数 | 参数指纹去重缓存(第 17 章 FAQ §5.4) |
| 工具返回超长 | 工具返回几百 KB,挤爆上下文 | 工具返回截断 + 摘要;返回「分页引用」(第 05 章 §3.4) |
三个最重要的边界认知:
- 工具调用是「模型输出的一种」,不是「模型的承诺」 。模型可能在任何时候 停止输出工具调用、改变参数、调用奇怪的名字------程序必须把「模型发起了 工具调用」当作需要校验的输入 ,而不是可信的命令。校验在
ToolRegistry.call()统一做(第 07 章)。 - 工具数量有隐性成本 。每个工具都占
tools清单 token,且增加模型的选择 负担。30 个工具的 Agent 与 5 个工具的 Agent,tool_choice: auto的可靠性 天差地别------这就是第 05 章「动态工具子集」存在的理由。 - 工具描述会影响模型的行为,但不会绝对控制它 。描述写得再清楚,模型 仍可能「偷懒」直接编答案而不是调工具。要强制,用
tool_choice协议层 约束(§4.4),不要指望提示词。
5. 工具设计:好工具的标准
工具是 Agent 能力边界的决定因素。 模型再聪明,工具设计糟糕也没用。 一组经过实践检验的工具设计原则:
5.1 命名的原则
- 动词开头 :
search_documents、send_email、create_ticket, 让模型一眼看出「这是个动作」。 - 语义清晰 :
get_weather比w好;query_order比func1好。 - 避免歧义:两个工具描述相近时,模型容易选错。必要时合并或重命名。
5.2 描述(description)的艺术
描述是模型选择工具的主要依据,写作质量直接决定工具调用准确率:
- 说清「什么时候用」:「当用户询问物流状态时调用此工具,按订单号查询」。
- 说清输入约束:「order_id 必须是 8 位数字订单号,含字母时先提示用户」。
- 说清返回值含义:「返回 JSON:status(含 pending/shipped/delivered)与 eta」。
- 给 few-shot 例子:复杂工具可以在描述里给一个「例如:query_order('8847-223')」。
反面教材 :
description: "查询订单"------太模糊,模型不知道该在什么时候用、 用什么参数、结果怎么看。
5.3 入参 schema:精确到类型
用完整的 JSON Schema 描述参数(见 §6)。关键点:
- 每个参数写
description,说明格式与边界; required明确哪些必须;- 枚举值用
enum(如status: ["pending","shipped"]); - 数值给
minimum/maximum;字符串给minLength/maxLength; additionalProperties: false拒绝未知参数(防御模型乱传字段)。
5.4 返回值:机器可读 + 人类可读
工具返回给模型的 content 既要机器可读,又要模型能直接引用:
text
{"status":"shipped","carrier":"顺丰","eta":"2026-08-30"}
最佳实践:返回值用结构化文本(如单行 JSON 或清晰的键值行), 让模型可以直接提取引用。避免长文本 + 无关信息(浪费 token 且分散注意力)。
5.5 工具越「粗粒度」越好
- 坏 :
add、subtract、multiply、divide四个工具。 - 好 :一个
calculator工具,入参是表达式。
原则:工具应是「任务级」而非「操作级」。粒度太细让模型难以决策且占用 上下文窗口(每个工具都要在 tools 里占 token)。一个 10 步的子任务,最好是一个工具。
5.6 错误处理:让错误可被模型理解
工具失败时,返回的 content 要告诉模型为什么失败、可以怎么办:
text
错误:订单 8847-999 不存在。可能原因:订单号有误/已超期清理。请与用户确认订单号。
模型读到这个,会自主选择「重试、改参数、询问用户、还是换策略」。 如果错误只是一句 Error: 404,模型无从修复。
这对应 mini-agent
ToolRegistry.call()的设计:所有异常都转为{ content, isError: true },绝不让异常穿透循环(第 07 章)。
6. JSON Schema 速成
工具入参的 schema 遵循 JSON Schema 标准(draft-07 / 2020-12)。高频子集:
jsonc
{
"$schema": "https://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"order_id": {
"type": "string",
"pattern": "^[0-9]{8}$", // 8 位数字
"description": "8 位订单号"
},
"amount": {
"type": "number",
"minimum": 0,
"maximum": 100000
},
"items": {
"type": "array",
"items": { "type": "string" }
},
"priority": {
"type": "string",
"enum": ["low", "normal", "high"] // 枚举约束
}
},
"required": ["order_id"], // 必填
"additionalProperties": false // 拒绝未知字段
}
为什么必须校验? 模型可能输出任何参数------类型错误、缺字段、越界值、甚至被提示注入带进的危险内容。 未校验的参数直接进工具,轻则运行时崩溃,重则造成安全漏洞(第 11 章)。
mini-agent 在
src/util/json-schema.ts实现了覆盖常用关键字的迷你校验器,ToolRegistry.call()在每次执行前强制校验。生产环境可直接用ajv等完整实现。
7. 工具的运行时:注册、发现、执行
一个生产级的工具系统要有清晰的运行时结构(这也是 mini-agent 的 ToolRegistry):
css
注册: 工具实现(execute + schema) → register(name, tool)
发现: 给模型 → toDefinitions() 序列化成 tools 清单
校验: 模型参数 → validate(schema) → 通过/拒绝
执行: execute(args, context) → ToolOutput
回填: 结果 → role:'tool' 消息 → 回喂模型
工具执行时的上下文(ToolContext)通常要注入:
- 日志器:工具内部的操作日志(可观测性);
- 取消信号:用户中断/超时能让工具停止;
- 依赖句柄 :数据库、文件系统、记忆管理器等------通过注入而非工具内
new; - 权限边界:工具能访问什么资源,由调用方控制。
8. 调用大模型的工程清单
把前文浓缩成一份「调用大模型」的工程清单:
- 设置
max_tokens,防止无限输出; - 决策型调用用低 temperature,创意型才用高 temperature;
- 配置超时(连接超时 + 请求超时);
- 瞬时错误(429/5xx/网络)自动重试,指数退避 + 抖动;
- 记录每次调用的 token 用量(成本与可观测性);
- 流式时处理增量工具参数(按 index 累积、流末解析、失败兜底);
- 解析失败有兜底(默认值 / 重试 / 日志);
- 工具参数强制 JSON Schema 校验;
- 工具错误转成可理解的文本回喂,不抛异常;
- 支持多工具并行(只用于独立工具,限制单轮并行数);
- 用
tool_choice精确控制:该防呆时用required,该路由时指定工具; - 多模态输入按 token 预算压缩,优先「先转文本」降本。
9. 对照 mini-agent
| 概念 | 代码 | 说明 |
|---|---|---|
| Provider 抽象 | src/provider/types.ts ChatProvider |
Agent 只依赖接口 |
| OpenAI 适配器 | src/provider/openai.ts |
完整工具调用 + 流式 SSE |
| 离线模拟 | src/provider/mock.ts |
可脚本化,测试与演示 |
| 工具注册/校验/执行 | src/tools/registry.ts |
JSON Schema 校验 + 错误回喂 |
| 内置工具 | src/tools/builtin.ts |
calculator/read_file/write_file/remember... |
| 安全表达式 | src/util/calc.ts |
拒绝任意代码执行 |
| Schema 校验 | src/util/json-schema.ts |
迷你但覆盖常用关键字 |
示例 :用真实模型跑 mini-agent,只需要把 MockProvider 换成 OpenAIProvider:
ts
import { OpenAIProvider } from './src/provider/openai.ts';
const provider = new OpenAIProvider({
baseURL: process.env.OPENAI_BASE_URL, // 兼容 Ollama/vLLM 等
model: process.env.AGENT_MODEL ?? 'gpt-4o-mini',
apiKey: process.env.OPENAI_API_KEY,
});
// 其余 Agent 代码一行不改
10. 本节要点
- Chat Completion 接口是 Agent 的地基,
messages数组承载全部状态; - 上下文窗口是硬约束,注意「lost in the middle」,关键信息放头尾;
- 决策型调用用低温度,提高确定性;
- 流式输出改善体验,工具参数要增量拼接、末尾解析;
- 结构化输出用 JSON Schema + 约束解码,别裸用「提示+解析」;
- Function Calling 让模型「发出行动请求」,
tool_call_id是配对契约; - Prompt Caching 是成本工程最大杠杆之一:稳定前缀在前、动态内容在后;
tool_choice是精确控制工具行为的手段:required做防呆、指定工具做路由;- 并行调用 只用于独立工具、嵌套调用 是 ReAct 循环的自然形态, 用
maxIterations限制调用深度; - 工具设计决定能力边界:动词命名、精确描述、完整 schema、粗粒度、可读错误;
- 工具参数必须校验 ,错误必须回喂给模型而非抛异常;
- 多模态输入扩展 Agent 的感知边界,但优先「先转文本」控制成本;
- JSON mode ≠ 符合 schema :
json_object只保证能 parse,json_schema才保证结构正确;程序要解析就用约束解码; - 工具调用是「需要校验的输入」,不是「模型的承诺」------模型可能在任何 时候变卦,边界要兜底:调用数上限、参数截断、幻觉工具名、空参数、 循环调用,全部要处理。
下一章,处理 Agent 的「大脑皮层」------记忆管理。