s05 - 结果回流
前四章,模型调了工具,拿到结果,然后呢?然后就没了。 这一章要做的事情,是让工具的结果回到模型手里,让它自己决定下一步干什么。 这一步跨过去,工具调用就变成了推理链。你的程序,就从"调用器"变成了 Agent。
这一章要解决什么问题
s04 的代码已经有了工具注册表和 dispatch 机制。模型能调工具了。但有一个问题:模型调完工具之后,拿到结果就直接回复用户了。
它不会想:"我看到了 package.json 里有 React,但我还想看看 README 确认一下。"
它不会追问:"search_files 搜到了三个文件,我需要逐个读一下才能回答。"
在 s04 里,工具调用是一次性的——调完就停。但真正的 Agent 应该是多步的——调一个工具,看看结果,再决定要不要调下一个。
这一章的目标:让模型能根据工具返回的结果,自主决定下一步行动。
回流是什么意思
先画一张图,对比 s04 和 s05 的区别:
s04:一次性调用
用户提问
↓
模型决定调工具 → 执行工具 → 拿到结果 → 模型回复用户工具的结果不会被"再看一次"。模型一次性决定调什么,拿到结果就输出。
s05:结果回流
用户提问
↓
模型决定调工具 A → 执行 → 结果送回模型
↓
模型看了结果,决定调工具 B → 执行 → 结果送回模型
↓
模型看了结果,决定调工具 C → 执行 → 结果送回模型
↓
模型觉得够了 → 回复用户关键区别:工具的结果不是终点,而是模型下一轮决策的输入。
每一次工具执行完,结果会被放回 messages 列表里。模型再调 API 的时候,它能看到之前所有的工具调用和结果。于是它能基于已有信息,决定下一步该做什么。
这就是"结果回流"——工具的输出流回了模型的输入。
代码
完整代码在 code/s05/agent.py。工具实现和注册表跟 s04 一样,变化集中在主循环。我们只看变化的部分。
MAX_ROUNDS:安全阀
MAX_ROUNDS = 10多步调用意味着循环可能跑很多轮。如果模型犯了错,反复调用同一个工具,程序会一直跑下去。所以加一个上限:最多执行 10 轮工具调用。到了就停。
这是防御性编程。不是怕模型太聪明,是怕它犯蠢。
核心循环:for + break
def run(user_message: str):
messages = [
{"role": "system", "content": "你是一个有文件访问能力的助手。..."},
{"role": "user", "content": user_message},
]
for round_num in range(1, MAX_ROUNDS + 1):
response = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=tools,
)
msg = response.choices[0].message
# 情况 1:模型没调工具,直接回复 → 结束
if not msg.tool_calls:
print(f"[最终回复]\n{msg.content}")
messages.append({"role": "assistant", "content": msg.content})
break
# 情况 2:模型调了工具 → 执行,结果送回
messages.append(msg)
for tc in msg.tool_calls:
func_name = tc.function.name
func_args = json.loads(tc.function.arguments)
result = dispatch_tool(func_name, func_args)
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": result,
})
else:
print(f"[警告] 达到最大轮次 {MAX_ROUNDS},停止执行。")用 for ... range(MAX_ROUNDS) 而不是 while True,因为 for 循环天然有上限。
else 子句跟 for 搭配使用:如果 for 循环没有被 break 中断(跑满了所有轮次),就会执行 else 里的代码。这是 Python 的一个冷门语法,但在这里刚好用上——如果循环是因为跑满而结束的,说明出了问题,打印警告。
对比 s04 的 inner loop
s04 里也有一个 while True 循环处理工具调用。但那个循环有个隐含假设:模型最多调一轮工具就该回复了。while 循环只是用来处理模型在一轮里返回多个工具调用的情况。
s05 的 for 循环是真正的多轮循环。每一轮,模型可以调工具,也可以直接回复。调了工具就继续下一轮,不调就 break 退出。
# s04 的逻辑(简化)
while True:
response = call_model()
if no_tool_calls:
print(reply)
break
execute_tools() # 执行工具,结果放回 messages
# 然后就 break 了,没有再调模型
# s05 的逻辑(简化)
for round in range(MAX_ROUNDS):
response = call_model()
if no_tool_calls:
print(reply)
break # 模型决定结束了
execute_tools() # 执行工具,结果放回 messages
# 继续循环 → 模型会再看结果,决定下一步看它跑一次
假设项目里有这些文件:
my-project/
├── package.json
├── README.md
├── src/
│ └── index.js
└── tests/
└── test_index.js运行 Agent,问:"帮我看看这个项目用了什么技术栈。"
你:帮我看看这个项目用了什么技术栈
======================================== 第 1 轮 ========================================
[调用工具] list_files({"path": "."})
[工具结果] README.md
package.json
src
tests
======================================== 第 2 轮 ========================================
[调用工具] read_file({"path": "package.json"})
[工具结果] {
"name": "my-project",
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"express": "^4.18.2"
},
"devDependencies": {
"jest": "^29.7.0"
}
}
======================================== 第 3 轮 ========================================
[调用工具] read_file({"path": "README.md"})
[工具结果] # My Project
A full-stack web application with React frontend and Express backend.
======================================== 第 4 轮 ========================================
[最终回复]
这个项目的技术栈如下:
- 前端:React 18.2
- 后端:Express 4.18(Node.js)
- 测试:Jest 29.7
- 类型:全栈 Web 应用
前后端分离架构,React 做前端渲染,Express 提供 API 服务。四轮。模型先列目录,看到 package.json 和 README.md,决定读 package.json,发现是 React + Express,又读 README 确认了"全栈"这个判断,最后综合两份文件的内容给出回答。
没有任何人告诉模型"先列目录再读 package.json 再读 README"。 这是它自己决定的。每一步都基于上一步的结果。
messages 里长什么样
跑完之后,messages 列表大概长这样:
[
{"role": "system", "content": "你是一个有文件访问能力的助手。..."},
{"role": "user", "content": "帮我看看这个项目用了什么技术栈"},
# 第 1 轮:模型决定调 list_files
{"role": "assistant", "tool_calls": [...]},
{"role": "tool", "tool_call_id": "xxx", "content": "README.md\npackage.json\nsrc\ntests"},
# 第 2 轮:模型决定调 read_file
{"role": "assistant", "tool_calls": [...]},
{"role": "tool", "tool_call_id": "yyy", "content": "{ \"name\": ... }"},
# 第 3 轮:模型决定再调一个 read_file
{"role": "assistant", "tool_calls": [...]},
{"role": "tool", "tool_call_id": "zzz", "content": "# My Project\nA full-stack..."},
# 第 4 轮:模型觉得信息够了,直接回复
{"role": "assistant", "content": "这个项目的技术栈如下:..."},
]模型在第 4 轮调 API 的时候,它能看到前面所有的工具调用和结果。这就是它能"综合判断"的原因——不是因为它记性好,是因为你每次都把完整的历史喂给它了。
试着改改
1. 改 MAX_ROUNDS 的值
把 MAX_ROUNDS 改成 3,看看如果模型还没分析完就被强制停止会怎样。再改成 20,看看复杂任务能不能跑满。
2. 打印每轮的 messages 长度
在 for 循环开头加一行:
print(f" messages 里现在有 {len(messages)} 条消息")观察每一轮消息数量怎么增长。
3. 换一个问题
把入口的问题改成:
user_input = input("你:")已经做了。试试问这些问题:
- "这个项目的入口文件是什么?"
- "搜索一下项目里有没有用到 lodash"
- "帮我看看 tests 目录下有哪些测试文件"
观察模型的推理路径——它会根据问题的不同,选择不同的工具调用策略。
4. 限制工具结果长度
如果某个文件特别大,工具返回的内容会撑爆上下文。试试给 read_file 加一个截断:
def read_file(path: str) -> str:
try:
with open(path, "r", encoding="utf-8") as f:
content = f.read()
if len(content) > 5000:
return content[:5000] + "\n... (文件过长,已截断)"
return content
except Exception as e:
return f"错误:{e}"教学边界
这一章只做一件事:让工具的结果回到模型手里,形成多步推理。
不涉及:
- 并行执行(工具还是一个一个跑的)
- 工具之间的依赖管理
- 用户中途打断或修改方向
- 流式输出
- 任何持久化
这些都在后面的章节里。
Phase 1 完结:你已经有了一个 Agent
回顾一下我们从 s01 到 s05 构建的东西:
| 章节 | 你加了什么 | 程序变成了什么 |
|---|---|---|
| s01 | API 调用 | 能跟模型说一句话 |
| s02 | 循环 | 能跟模型持续对话 |
| s03 | 一个工具 | 模型能读文件了 |
| s04 | 注册表 + dispatch | 工具可扩展了 |
| s05 | 结果回流 | 模型能多步推理了 |
s01 到 s04,你的程序是一个工具调用器——用户问,模型答,中间可能调一次工具。
s05 加上结果回流之后,你的程序是一个Agent——用户问一个问题,模型自己规划步骤、调用工具、分析结果、决定下一步、直到得出结论。
这个转变看起来很小——不就是把工具结果放回 messages 里嘛——但它改变的是程序的控制权。之前是你写死"调哪个工具、怎么用结果"。现在是模型自己决定。
这就是 Agent 的核心:模型掌握控制权,工具只是它的手和眼。
一句话记住
结果回流让工具的输出成为模型下一轮的输入——模型不再只是调用工具,它在思考该调什么。控制权从你手里交到了模型手里。