Agent 实践:跑通第一个 Tool Calling Loop
版本说明:本文保留为已发布快照。持续更新、经过源码核验和实践验证的版本,见 《Agent 工程实践》第 3 课。后续完整正文只在书籍仓库维护。
前两课已经解决概念问题:Agent 与 Workflow 的分界在于谁决定下一步;运行时则可以拆成 model、harness、tools 和 environment。本课只做一件事:亲手跑通第一条 OpenAI-compatible Tool Calling Agent Loop。
最终消息序列应该是:
1 | User |
代码会限制最大步骤数、验证工具参数,并拒绝把被截断的模型输出当成正常 Final。
1. 模型只申请调用,Python 才执行工具
用户要求计算 248 × 15。第一次请求中,模型拿到 User Message 和工具 Schema:
1 | User: 帮我算一下 248 乘以 15 |
模型不会执行乘法,而是返回一张工具订单:
1 | { |
Python 校验参数并执行固定的 a * b,再把结果作为 role="tool" 消息追加到历史。第二次请求中,模型看到真实结果 3720,才生成最终自然语言回答。
这是一条普通 Tool Calling Agent Loop,不是在复现 ReAct 论文。ReAct 还要求推理轨迹与任务动作交错生成;“模型调用工具并根据结果继续”本身不足以证明采用了 ReAct。
2. 不要让模型控制 eval()
很多计算器教程会写:
1 | eval(expression) |
这里的 expression 来自模型,而模型又受用户和外部内容影响。eval() 执行的是 Python 代码,不是受限数学语言;它可以读文件、导入模块或启动进程。Python 官方文档也明确警告不要对不可信输入使用它。Python eval()
本课不实现表达式解析器,只暴露两个整数:
1 | MAX_ABS_VALUE = 1_000_000 |
模型只提供数据,程序执行固定运算。这个接口不支持任意表达式,但它的安全边界清楚,足够证明工具协议。
3. Schema 和执行侧要同时校验
工具 Schema 告诉模型正确参数长什么样:
1 | TOOLS = [ |
Schema 不能替代本地验证。Schema 是告诉模型“参数应该怎样填写”的说明;本地校验才是执行前真正的门。模型可能生成坏 JSON、未知工具、错误类型或额外字段,第三方兼容端点的 Schema 校验严格程度也可能不同。
例如 {"a":"248","b":15} 中的 "248" 是字符串,程序会返回受控错误,不替模型猜测并转换类型。Python 还把 bool 视为 int 的子类:isinstance(True, int) 是 True。因此 multiply() 必须先拒绝布尔值,否则 multiply(True, 15) 会悄悄得到 15。
执行 Router 因此继续检查:
1 | def execute_tool(tool_call: object) -> str: |
未知或非法参数会变成受控 Tool Result,模型可以看到错误、修正参数或改用其他工具,但危险代码不会执行。只有响应缺少 ID、Tool Call 被截断、协议字段互相矛盾等内部状态已经不可信时,Harness 才直接终止整个 Loop。
4. 有限步 Agent Loop
Agent Loop 的核心是两次模型请求,中间夹着一次本地工具执行:
1 | MAX_STEPS = 8 |
message 与 finish_reason 不是两个来源。它们都属于同一个 choice:
| 字段 | 回答什么 |
|---|---|
choice.message.tool_calls | Model 生成了哪些工具请求 |
choice.finish_reason | Provider 为什么停止本次生成 |
正常工具调用应同时满足“消息里有 Tool Call”和 finish_reason="tool_calls"。若消息里有调用,停止原因却是 length,参数可能只生成了一半;Harness 不能执行这种内部不一致的响应。
finish_reason="length" 表示输出因 Token 上限被截断,content_filter 代表内容被过滤。它们都不能伪装成成功 Final。MAX_STEPS 也不是解决循环根因,只是防止程序无限消耗额度的最后止损线。
这里还有一个容易忽略的边界:step 表示第几次模型请求,不是第几条消息。若第 8 次循环(step == MAX_STEPS)仍返回 Tool Call,当前代码会执行工具并把结果追加到本地 messages,随后循环结束并抛出“超过最大步骤数”。它不会再发起第 9 次模型请求,所以 Model 看不到这个最后结果。上限负责强制停车,不负责让最后一次调用自动收尾。
5. tool_call_id 为什么不能丢
Assistant Message 可能一次请求多个工具。每个调用都有自己的 ID:
1 | call_1 → multiply(248, 15) |
Tool Message 必须带原 ID:
1 | { |
tool_call_id 是订单号。没有它,模型无法判断哪份结果回答哪次调用。OpenAI Chat Completions 协议要求 Tool Message 携带匹配 ID;第三方兼容端点是否同样严格,要看各自实现,应用不应依赖宽松行为。OpenAI Function Calling
本课实际使用三种 Message:
| Role | 保存什么 |
|---|---|
user | 用户需求 |
assistant | 模型文本或 tool_calls |
tool | 带 tool_call_id 的真实工具结果 |
developer 与 system 用于开发者指令,工具定义则位于请求顶层 tools 参数。不要把 Anthropic 的 tool_use/tool_result Content Block 与这里的 role="tool" 混成一套协议。
6. 运行与自检
完整代码见 lesson_03_tool_calling_loop.py。首次运行先克隆仓库:
先运行不需要 API Key 的自检:
1 | git clone https://github.com/unix2dos/agent-engineering-book.git |
自检使用假的模型响应,验证:
1 | multiply 参数校验 |
预期结尾:
1 | self-check passed |
--self-check 只验证本地代码,不访问 SDK、网络、Provider 或真实 Model。它通过,说明 Router、参数校验、ID 配对和主要控制流可用;不代表 API Key 正确,也不代表兼容端点和所选模型支持 Tool Calling。
在线失败时,沿实际调用链寻找“最后一个成功事件”和“第一个失败事件”:
1 | Harness → SDK/网络 → Provider → Model |
若 Provider 在第一次请求就返回“模型不支持 tools”,本地 multiply() 尚未运行,应先检查模型能力、模型名称、接口路径和兼容端点,而不是修改 Tool。
在线运行需要 OpenAI Python SDK,以及一个支持 Tool Calling 的 OpenAI-compatible Provider:
1 | python -m pip install openai |
代码不会读取任何 Provider 的本地登录文件。本次核验使用 openai-python v3.5.0 的当前接口与一个 OpenAI-compatible 端点;兼容端点仍需自己保证所选模型支持 Tool Calling。
运行时只打印 Harness 能观察到的步骤,不输出 provider 私有 reasoning 字段:
1 | step 1: finish_reason=tool_calls |
7. 下一课从哪里接上
本课结束时,messages 已经形成一个完整 Turn:
1 | User |
但它只存在于 Python 进程内存。程序退出,消息就丢失;代码也没有输入预算、摘要或长期记忆。要恢复这组消息和运行进度,首先需要 Session 或 Checkpoint。只有 Tool 会产生邮件、付款、文件写入等外部副作用,并且崩溃后无法确认是否执行过时,才进一步需要 Execution Ledger、幂等或人工核对。纯整数乘法没有这类副作用。
下一课 Agent 上下文:Session、Checkpoint 与长期记忆 就从这组 messages 出发,解释怎样保存、选择和压缩历史,并区分 Checkpoint、项目记忆与用户记忆。
主动回忆自测
读完后合上文章,再口头回答:
- 完整 Tool Calling Turn 的四条消息按什么顺序出现?
- 为什么不能把模型生成的表达式直接交给
eval()? - 有了 Tool Schema,执行侧为什么仍要验证 JSON、字段和类型?
- 为什么必须先保存 Assistant Tool Call,再保存对应的 Tool Result?
message.tool_calls与choice.finish_reason分别说明什么?MAX_STEPS限制的是模型请求次数还是消息数量?它能解决什么,又不能解决什么?- 为什么
multiply()要单独拒绝bool? --self-check能证明什么,不能证明什么?- 未知工具为什么通常返回受控错误,什么情况才直接终止 Loop?
- 进程在 Tool Result 产生后崩溃,Session、Checkpoint 与 Ledger 分别解决什么?
展开查看简答
user → assistant(tool_calls) → tool(tool_call_id, result) → assistant(final)。eval()会执行不可信 Python 代码;固定的multiply(a, b)只接收数据并执行固定运算。- Schema 负责引导生成,本地校验才守住执行边界;模型或兼容端点仍可能产生坏 JSON、错误类型和额外字段。
- Tool Result 必须用 ID 回答历史中已经存在的 Tool Call,否则请求与回执无法配对。
- 前者保存模型生成的调用;后者说明 Provider 为什么停止本次生成。两者必须保持一致。
- 它限制模型请求次数,从而限制时间和费用;它不直接限制消息条数,也不能修复模型循环、工具反复报错或 Prompt 缺少停止条件的根因。
- Python 中
bool是int的子类;不显式拒绝时,True会被当作1。 - 它验证本地 Router、消息组织和控制流;不验证凭据、网络、Provider 或真实模型能力。
- 受控错误让模型修正请求;缺失 ID、截断参数或协议状态不一致时,Harness 应直接终止。
- Session 保存会话消息;Checkpoint 保存程序可恢复到哪里;Ledger 记录副作用工具实际执行过什么;幂等保证同一业务请求重试时不会重复产生副作用。