Skip to content

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:安全阀

python
MAX_ROUNDS = 10

多步调用意味着循环可能跑很多轮。如果模型犯了错,反复调用同一个工具,程序会一直跑下去。所以加一个上限:最多执行 10 轮工具调用。到了就停。

这是防御性编程。不是怕模型太聪明,是怕它犯蠢。

核心循环:for + break

python
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 退出。

python
# 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 列表大概长这样:

python
[
    {"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 循环开头加一行:

python
print(f"  messages 里现在有 {len(messages)} 条消息")

观察每一轮消息数量怎么增长。

3. 换一个问题

把入口的问题改成:

python
user_input = input("你:")

已经做了。试试问这些问题:

  • "这个项目的入口文件是什么?"
  • "搜索一下项目里有没有用到 lodash"
  • "帮我看看 tests 目录下有哪些测试文件"

观察模型的推理路径——它会根据问题的不同,选择不同的工具调用策略。

4. 限制工具结果长度

如果某个文件特别大,工具返回的内容会撑爆上下文。试试给 read_file 加一个截断:

python
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 构建的东西:

章节你加了什么程序变成了什么
s01API 调用能跟模型说一句话
s02循环能跟模型持续对话
s03一个工具模型能读文件了
s04注册表 + dispatch工具可扩展了
s05结果回流模型能多步推理了

s01 到 s04,你的程序是一个工具调用器——用户问,模型答,中间可能调一次工具。

s05 加上结果回流之后,你的程序是一个Agent——用户问一个问题,模型自己规划步骤、调用工具、分析结果、决定下一步、直到得出结论。

这个转变看起来很小——不就是把工具结果放回 messages 里嘛——但它改变的是程序的控制权。之前是你写死"调哪个工具、怎么用结果"。现在是模型自己决定。

这就是 Agent 的核心:模型掌握控制权,工具只是它的手和眼。

一句话记住

结果回流让工具的输出成为模型下一轮的输入——模型不再只是调用工具,它在思考该调什么。控制权从你手里交到了模型手里。