Agent 实践:跑通第一个 Tool Calling Loop

版本说明:本文保留为已发布快照。持续更新、经过源码核验和实践验证的版本,见 《Agent 工程实践》第 3 课。后续完整正文只在书籍仓库维护。

前两课已经解决概念问题:Agent 与 Workflow 的分界在于谁决定下一步;运行时则可以拆成 model、harness、tools 和 environment。本课只做一件事:亲手跑通第一条 OpenAI-compatible Tool Calling Agent Loop。

最终消息序列应该是:

1
2
3
4
User
→ Assistant(tool_calls)
→ Tool(tool_call_id, result)
→ Assistant(final)

代码会限制最大步骤数、验证工具参数,并拒绝把被截断的模型输出当成正常 Final。

1. 模型只申请调用,Python 才执行工具

用户要求计算 248 × 15。第一次请求中,模型拿到 User Message 和工具 Schema:

1
2
User: 帮我算一下 248 乘以 15
Tools: multiply(a: integer, b: integer)

模型不会执行乘法,而是返回一张工具订单:

1
2
3
4
5
6
7
8
{
"id": "call_1",
"type": "function",
"function": {
"name": "multiply",
"arguments": "{\"a\":248,\"b\":15}"
}
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
MAX_ABS_VALUE = 1_000_000


def multiply(a: int, b: int) -> int:
if (
isinstance(a, bool)
or isinstance(b, bool)
or not isinstance(a, int)
or not isinstance(b, int)
):
raise ValueError("a 和 b 必须是整数")
if abs(a) > MAX_ABS_VALUE or abs(b) > MAX_ABS_VALUE:
raise ValueError(f"a 和 b 的绝对值不能超过 {MAX_ABS_VALUE}")
return a * b

模型只提供数据,程序执行固定运算。这个接口不支持任意表达式,但它的安全边界清楚,足够证明工具协议。

3. Schema 和执行侧要同时校验

工具 Schema 告诉模型正确参数长什么样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
TOOLS = [
{
"type": "function",
"function": {
"name": "multiply",
"description": "Multiply two integers",
"parameters": {
"type": "object",
"properties": {
"a": {"type": "integer"},
"b": {"type": "integer"},
},
"required": ["a", "b"],
"additionalProperties": False,
},
},
}
]

Schema 不能替代本地验证。Schema 是告诉模型“参数应该怎样填写”的说明;本地校验才是执行前真正的门。模型可能生成坏 JSON、未知工具、错误类型或额外字段,第三方兼容端点的 Schema 校验严格程度也可能不同。

例如 {"a":"248","b":15} 中的 "248" 是字符串,程序会返回受控错误,不替模型猜测并转换类型。Python 还把 bool 视为 int 的子类:isinstance(True, int)True。因此 multiply() 必须先拒绝布尔值,否则 multiply(True, 15) 会悄悄得到 15

执行 Router 因此继续检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def execute_tool(tool_call: object) -> str:
try:
if tool_call.function.name != "multiply":
raise ValueError(f"未知工具:{tool_call.function.name}")
arguments = json.loads(tool_call.function.arguments)
if not isinstance(arguments, dict):
raise ValueError("工具参数必须是 JSON 对象")
if set(arguments) != {"a", "b"}:
raise ValueError("multiply 只接受 a 和 b")
result = multiply(arguments["a"], arguments["b"])
payload = {"status": "completed", "result": result}
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
payload = {"status": "error", "message": str(error)}
return json.dumps(payload, ensure_ascii=False)

未知或非法参数会变成受控 Tool Result,模型可以看到错误、修正参数或改用其他工具,但危险代码不会执行。只有响应缺少 ID、Tool Call 被截断、协议字段互相矛盾等内部状态已经不可信时,Harness 才直接终止整个 Loop。

4. 有限步 Agent Loop

Agent Loop 的核心是两次模型请求,中间夹着一次本地工具执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
MAX_STEPS = 8


def run_agent(client: object, model: str, user_text: str) -> str:
messages = [{"role": "user", "content": user_text}]

for step in range(1, MAX_STEPS + 1):
response = client.chat.completions.create(
model=model,
messages=messages,
tools=TOOLS,
)
choice = response.choices[0]
message = choice.message
messages.append(assistant_message_from_api(message))

if message.tool_calls:
if choice.finish_reason != "tool_calls":
raise RuntimeError("Tool Call 与 finish_reason 不一致")
for tool_call in message.tool_calls:
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": execute_tool(tool_call),
}
)
continue

if choice.finish_reason == "stop":
return message.content or ""

raise RuntimeError(f"模型没有正常结束:{choice.finish_reason}")

raise RuntimeError(f"超过最大步骤数:{MAX_STEPS}")

messagefinish_reason 不是两个来源。它们都属于同一个 choice

字段回答什么
choice.message.tool_callsModel 生成了哪些工具请求
choice.finish_reasonProvider 为什么停止本次生成

正常工具调用应同时满足“消息里有 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
2
call_1 → multiply(248, 15)
call_2 → multiply(6, 7)

Tool Message 必须带原 ID:

1
2
3
4
5
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": result,
}

tool_call_id 是订单号。没有它,模型无法判断哪份结果回答哪次调用。OpenAI Chat Completions 协议要求 Tool Message 携带匹配 ID;第三方兼容端点是否同样严格,要看各自实现,应用不应依赖宽松行为。OpenAI Function Calling

本课实际使用三种 Message:

Role保存什么
user用户需求
assistant模型文本或 tool_calls
tooltool_call_id 的真实工具结果

developersystem 用于开发者指令,工具定义则位于请求顶层 tools 参数。不要把 Anthropic 的 tool_use/tool_result Content Block 与这里的 role="tool" 混成一套协议。

6. 运行与自检

完整代码见 lesson_03_tool_calling_loop.py。首次运行先克隆仓库:

先运行不需要 API Key 的自检:

1
2
3
git clone https://github.com/unix2dos/agent-engineering-book.git
cd agent-engineering-book
python examples/lesson_03_tool_calling_loop.py --self-check

自检使用假的模型响应,验证:

1
2
3
4
multiply 参数校验
Tool Call 与 Tool Result ID 配对
第二次模型请求获得 3720
finish_reason=length 不会被当作 Final

预期结尾:

1
self-check passed

--self-check 只验证本地代码,不访问 SDK、网络、Provider 或真实 Model。它通过,说明 Router、参数校验、ID 配对和主要控制流可用;不代表 API Key 正确,也不代表兼容端点和所选模型支持 Tool Calling。

在线失败时,沿实际调用链寻找“最后一个成功事件”和“第一个失败事件”:

1
2
3
Harness → SDK/网络 → Provider → Model

Tool ← Router ← Assistant(tool_calls)

若 Provider 在第一次请求就返回“模型不支持 tools”,本地 multiply() 尚未运行,应先检查模型能力、模型名称、接口路径和兼容端点,而不是修改 Tool。

在线运行需要 OpenAI Python SDK,以及一个支持 Tool Calling 的 OpenAI-compatible Provider:

1
2
3
4
5
6
7
8
9
python -m pip install openai

export OPENAI_API_KEY="your-api-key"
export OPENAI_MODEL="your-model"

# 第三方兼容端点才需要设置;OpenAI 官方端点可以省略。
export OPENAI_BASE_URL="https://provider.example/v1"

python examples/lesson_03_tool_calling_loop.py

代码不会读取任何 Provider 的本地登录文件。本次核验使用 openai-python v3.5.0 的当前接口与一个 OpenAI-compatible 端点;兼容端点仍需自己保证所选模型支持 Tool Calling。

运行时只打印 Harness 能观察到的步骤,不输出 provider 私有 reasoning 字段:

1
2
3
4
step 1: finish_reason=tool_calls
step 1: multiply call_id=call_1 result={"status":"completed","result":3720}
step 2: finish_reason=stop
Agent> 248 乘以 15 等于 3720。

7. 下一课从哪里接上

本课结束时,messages 已经形成一个完整 Turn:

1
2
3
4
User
→ Assistant(tool_calls)
→ Tool(tool_call_id, result)
→ Assistant(final)

但它只存在于 Python 进程内存。程序退出,消息就丢失;代码也没有输入预算、摘要或长期记忆。要恢复这组消息和运行进度,首先需要 Session 或 Checkpoint。只有 Tool 会产生邮件、付款、文件写入等外部副作用,并且崩溃后无法确认是否执行过时,才进一步需要 Execution Ledger、幂等或人工核对。纯整数乘法没有这类副作用。

下一课 Agent 上下文:Session、Checkpoint 与长期记忆 就从这组 messages 出发,解释怎样保存、选择和压缩历史,并区分 Checkpoint、项目记忆与用户记忆。

主动回忆自测

读完后合上文章,再口头回答:

  1. 完整 Tool Calling Turn 的四条消息按什么顺序出现?
  2. 为什么不能把模型生成的表达式直接交给 eval()
  3. 有了 Tool Schema,执行侧为什么仍要验证 JSON、字段和类型?
  4. 为什么必须先保存 Assistant Tool Call,再保存对应的 Tool Result?
  5. message.tool_callschoice.finish_reason 分别说明什么?
  6. MAX_STEPS 限制的是模型请求次数还是消息数量?它能解决什么,又不能解决什么?
  7. 为什么 multiply() 要单独拒绝 bool
  8. --self-check 能证明什么,不能证明什么?
  9. 未知工具为什么通常返回受控错误,什么情况才直接终止 Loop?
  10. 进程在 Tool Result 产生后崩溃,Session、Checkpoint 与 Ledger 分别解决什么?
展开查看简答
  1. user → assistant(tool_calls) → tool(tool_call_id, result) → assistant(final)
  2. eval() 会执行不可信 Python 代码;固定的 multiply(a, b) 只接收数据并执行固定运算。
  3. Schema 负责引导生成,本地校验才守住执行边界;模型或兼容端点仍可能产生坏 JSON、错误类型和额外字段。
  4. Tool Result 必须用 ID 回答历史中已经存在的 Tool Call,否则请求与回执无法配对。
  5. 前者保存模型生成的调用;后者说明 Provider 为什么停止本次生成。两者必须保持一致。
  6. 它限制模型请求次数,从而限制时间和费用;它不直接限制消息条数,也不能修复模型循环、工具反复报错或 Prompt 缺少停止条件的根因。
  7. Python 中 boolint 的子类;不显式拒绝时,True 会被当作 1
  8. 它验证本地 Router、消息组织和控制流;不验证凭据、网络、Provider 或真实模型能力。
  9. 受控错误让模型修正请求;缺失 ID、截断参数或协议状态不一致时,Harness 应直接终止。
  10. Session 保存会话消息;Checkpoint 保存程序可恢复到哪里;Ledger 记录副作用工具实际执行过什么;幂等保证同一业务请求重试时不会重复产生副作用。

参考资料