CMU AI AGENTS / 中文直观学习 / 第 02 课

工具跑完了,
为什么模型还要再调用一次?

第一次生成“请去查”,第二次根据“查到了什么”作答。把两份输入放在一起,看清中间多了哪些消息,又是谁把它们接起来。

约 25–30 分钟前置:第一课的库存查询6 页官方课件原图 · 逐段对照

01 / 文件读完了,但用户的问题还没回答

接着上一课。用户问:“我需要 3 盒红笔。库存够吗?如果够,一共多少钱?”程序执行读取,拿到了下面这份数据:

{"item":"红笔","stock":5,"unit_price":12,"currency":"CNY"}

现在暂停。这个返回值告诉你有 5 盒、每盒 12 元,却没有说用户要几盒,也没有说本次要付多少钱。因为工具的工作是查库存和单价,它不负责理解用户的整段任务。

要得到“够,3 盒共 36 元”,还需要把两处信息合起来:用户任务里的 3,加上工具结果里的 5 和 12。在本课的 Agent 中,这一步交给模型完成,所以程序要带着新证据再请求模型一次。

第一次:不知道库存 → 生成查询请求
工具执行:查到库存和单价 → 返回数据
第二次:原任务 + 新数据 → 生成回答

这里容易误会成“模型一直在线等文件读完,再接着想”。我们先采用一个更容易检查的视角:第一次生成已经返回,程序接手;第二次是带着更多输入发起的一次新生成。 模型可以还是同一个,权重也没有因为读了文件而改变。

先预测

如果第二次只发送库存 JSON,删掉用户原话,模型还能知道应该算 2 盒还是 3 盒吗?

展开解释

不能仅凭这份 JSON 确定用户需求。stock: 5 表示现有库存,不表示用户想买 5 盒。用户任务和环境数据是两个不同来源,不能拿一个替代另一个。

02 / 第一次请求里,到底装了什么?

先把“提示词”这个大词拆开。程序向模型提交的输入通常不止用户一句话。在这个例子中,有三种内容:规则、任务、可用工具说明。

messages · 消息

system:根据工具证据回答;未知时说明缺少什么。这里只查询,不下单。

user:我需要 3 盒红笔。库存够吗?如果够,一共多少钱?

tools · 工具说明

名称:read_inventory

用途:读取指定商品的库存与单价。

参数:item,商品名称,字符串。

这里没有 stock: 5,也没有 unit_price: 12。

工具说明像一张“可以向谁问什么”的说明卡。它让模型知道有一个查询入口、该填什么参数;提供工具说明不等于已经执行工具,更不等于把数据库内容发给了模型。

官方原课件 · Lecture 2 · PDF 第 21 页工具说明与消息一起构成请求
原课第二讲第21页:左侧get_weather工具的JSON Schema,右侧请求同时携带messages与tools。

沿原图看:左边定义 get_weather,并规定 city 是字符串;右边请求同时带着 messages 和 tools。原课用天气查询,本课换成库存查询,关系完全一样:先告诉模型有什么接口,再由模型决定请求哪一个。图里没有当天的温度,正如我们的工具说明没有库存数。

system、user 这些角色,模型真的能直接“看见”吗?

在程序里,它们是带角色的消息对象。送进模型前,聊天模板会把这些对象组织成模型可处理的 token 序列。角色标记帮助区分规则、用户要求和先前输出。你现在不用记具体特殊符号,先理解:一组结构化消息最终会成为一次模型生成所依据的输入。

官方原课件 · Lecture 2 · PDF 第 20 页消息经过模板,变成一次模型输入
原课第二讲第20页:system、user、assistant三条API消息,经chat template变成带Qwen角色标记的输入序列。

看左边,再看右边:左侧每一行都有角色与内容;右侧把这些行串成一个序列。不是给每个角色调用一次模型。图中的 Qwen 标记是一个模板例子,不是所有模型都使用的统一协议。本课保留左侧消息视图,让你先检查信息是否完整。

因此,第一次输入的清单可以写成:

messages₁ = [system 规则, user 原任务]
tools     = [read_inventory 的说明与参数约束]

这两行里没有实时库存,模型此时合理的下一步是请求查询,而不是把一个猜测当作查到的数据。

03 / 第一次输出是“请去查”,不是“查到了”

第一次生成返回后,程序收到一条 assistant 消息。它携带一个工具调用请求。下面采用与原课相近的 Chat Completions 风格展示,省略外层响应元数据;这是一份教学消息,不是可直接运行的完整 API 示例。

{
  "role": "assistant",
  "content": null,
  "tool_calls": [{
    "id": "call_stock_1",
    "type": "function",
    "function": {
      "name": "read_inventory",
      "arguments": "{\"item\":\"红笔\"}"
    }
  }]
}

这段可以从外到内读:

content: null 只表示这个例子没有同时输出普通回答文字。它不表示“什么都没输出”:有意义的输出在 tool_calls 中。

官方原课件 · Lecture 2 · PDF 第 22 页工具定义是输入,工具请求是输出
原课第二讲第22页:左侧提示包含get_weather定义,右侧模型生成调用get_weather并传入Pittsburgh的内容,API响应附带调用ID。

原图里的变化:左侧是“可以查天气”的定义;右侧已经选定 get_weather,并填入 Pittsburgh。模型生成的是调用内容。课件还说明 API 响应附带唯一调用 ID;不要把整份响应的每个字段都理解为模型逐字生成的内容。

接下来轮到程序,而不是第三个模型

宿主程序识别请求、查找工具、解析与校验参数,再执行读取。工具返回库存记录。宿主将结果包成一条新的消息:

{
  "role": "tool",
  "tool_call_id": "call_stock_1",
  "content": "{\"item\":\"红笔\",\"stock\":5,\"unit_price\":12,\"currency\":\"CNY\"}"
}

这不是一次模型生成。role: tool 表明内容来自工具执行路径;程序把返回值放进了 content。本例读文件这一步只增加工具执行次数,不增加模型调用次数。 其他系统的工具内部也可能调用模型,那就需要分开统计,本课没有这种嵌套。

官方原课件 · Lecture 2 · PDF 第 25 页请求与返回结果,靠同一个 ID 对上
原课第二讲第25页:get_weather请求的id为call_123,tool消息的tool_call_id也为call_123,结果是72华氏度、晴。

先找相同的 call_123:它把右侧结果关联到左侧请求。原课天气结果是 72°F、晴;本课库存结果是 5 盒、12 元。两边的对应关系不变。结果不会凭空成为新用户要求,也不该与另一个调用混用。下一课再用两个并行请求深入检查错配问题。

04 / 第二次输入,不是把第一次扔掉重来

现在宿主手里有了四条消息。最小的拼接动作是:保留第一次输入,追加第一次输出,再追加工具结果。

  1. system · 保留规则

    继续按证据回答。这里仍然只是查询,没有授权下单。

  2. user · 保留原任务

    用户需要 3 盒,并且询问库存是否够、如果够要多少钱。

  3. assistant · 追加刚才的工具请求

    记录调用 read_inventory,参数 item 为红笔,ID 为 call_stock_1。

  4. tool · 追加配对的返回结果

    工具结果指向 call_stock_1,提供库存 5、单价 12。

messages₂ = messages₁
+ assistant 的工具请求
+ tool 的返回结果

这次请求仍提供工具说明,以便模型在需要更多信息时继续选择工具。不过在当前数据下,它已有足够证据生成答案:5 ≥ 3,所以够;3 × 12 = 36,所以共 36 元。

注意,此时还没有把“36 元”放进输入。最终回答是第二次生成的输出,不是我们预先塞进 messages₂ 的答案。 工具结果里的 12、任务里的 3,共同支持这一步计算。

为什么看起来像一场连续对话?

因为程序把前面的消息组织进后一次输入,用户看到的又是同一个对话窗口。连续性来自上下文的保留,不意味着模型会自动访问程序刚刚拿到的数据。某些服务允许用会话 ID 或前一响应 ID 延续上下文,缓存也可能减少重复计算;这些封装不会取消“新观察必须进入后续推理上下文”这个条件。

第二次调用也不是训练。我们改变的是本次生成可用的信息,没有在这个实验中改模型权重。至于实际系统怎样压缩长历史、复用缓存,留到上下文单元再展开。

05 / 亲自展开两次调用,不只盯着最后一句话

先逐步走完正常情况。左侧一直保留第一次输入,右侧显示第二次输入怎样组成;下面分别显示第一次请求、工具结果和第二次回答。蓝色标出新增的 assistant 请求,绿色标出新增的 tool 结果。手机上两份输入上下排列。

实验 02

同一个模型,两份不同的输入

固定规则的教学回放,不发送 API 请求。这里数的是模型生成请求;一次工具执行另计。

① 准备第一次输入

用户说需要 3 盒。此时只有任务,没有库存记录;工具说明告诉模型能查什么。

模型调用 0 次工具执行 0 次尚未提交第一次请求

第一次模型输入 一直保留,方便比较

system

根据工具证据回答;未知时说明缺少什么。这里只查询,不下单。

user

我需要 3 盒红笔。库存够吗?如果够,一共多少钱?

第二次模型输入 尚未组装

工具执行后,观察这里怎样多出两条消息。

两次请求共用的工具说明(tools)

说明只列接口,不包含查询结果。模型名称与生成参数从教学视图中省略。

[
  {
    "type": "function",
    "function": {
      "name": "read_inventory",
      "description": "读取指定商品的库存和单价。",
      "parameters": {
        "type": "object",
        "properties": {
          "item": {
            "type": "string"
          }
        },
        "required": [
          "item"
        ],
        "additionalProperties": false
      }
    }
  }
]

第一次模型输出 · 请求

等待第一次生成。

工具返回给程序 · 数据

还没执行工具。

第二次模型输出 · 回答

尚未发出第二次调用。

展开完整消息数组(随步骤更新)

第一次 messages

[
  {
    "role": "system",
    "content": "根据工具证据回答;未知时说明缺少什么。这里只查询,不下单。"
  },
  {
    "role": "user",
    "content": "我需要 3 盒红笔。库存够吗?如果够,一共多少钱?"
  }
]

第二次 messages · 尚未组装

尚未组装。
1 / 6
走完一次,再只改一个条件

改变条件会回到第一步。推荐先保持“正常保留 / 2 次”,再逐项对照。

消息借用原课的 Chat Completions 风格表示。错误处理与回答是本课规则,不能代表真实模型必然这样回答;缺少工具结果由本课宿主检查拦截。

对照一:只把“需要 3 盒”改成“需要 2 盒”

工具请求仍是查红笔,工具仍返回库存 5 和单价 12。改变的是 user 消息里的数量,最终价格变成 2 × 12 = 24。这说明用户目标和环境数据分别从不同消息进入判断。不要为了适配用户需求,偷偷把库存数据也改掉。

对照二:漏掉工具结果

保留 assistant 请求,却不追加对应的 tool 消息。此时右侧的消息数组停在“我请求查询”这一步,没有“我查到了什么”。本课宿主在提交前拦截这份不完整消息,所以模型调用数停在 1;画面显示的是宿主的停止原因,不是一次新的模型回答。

很多工具调用协议要求调用与结果正确配对。真实接口可能拒绝不完整请求;即使某种接口接受了缺少观察的输入,也不代表模型已知道库存。不要把上一课“没看到结果就说明未知”的概念回放误解成“任何 API 都能直接发送一个没配对的工具请求”。

对照三:保留结果,漏掉原任务

这次请求与结果配对都在,但 user 原任务被移除了。模型能看到库存和单价,却没有用户需要几盒这个依据。本课回放会询问数量,而不是擅自用库存 5 作为购买数量。有数据,不等于有足够的任务上下文。

对照四:工具读回的是错误

返回 error 仍然是一条可回填的观察:它告诉下一次模型调用“没有读到”。程序不应该把失败改成 stock: 0,因为那会把未知伪装成确定的缺货。第二次模型可以解释失败或请求重试;本课按“说明错误后停止”的规则演示。

06 / 第二次输出以后,循环怎样停?

“两次”是这个小任务刚好走过的次数,不是 Agent 的固定规定。第一次可能直接给出答案;第二次也可能请求另一个仓库的数据。宿主检查本轮输出:如果还有工具请求,就执行、回填、再生成;如果是可交付的最终回答,就结束这轮任务。

官方原课件 · Lecture 1 · PDF 第 14 页同一个模型被放进循环,历史跟着任务更新
原课第一讲第14页:Context包含Task、Tool与History,经Model与Reasoning产生Call,示例最终向用户说明test.py定义了add函数。

先看左侧 History:模型下一步依赖的上下文不只有原任务,还包括已经发生的动作与观察。这张 PDF 是课件中的一个静态状态,右侧展示向用户发送结果;本课的六步实验把它前后的信息变化展开了。我们的库存例子是教学改编,不是这张原图里的 test.py 案例。

官方原课件 · Lecture 1 · PDF 第 15 页原课的最小循环:query、保存消息、执行动作
原课第一讲第15页mini-swe-agent代码:run初始化system和user消息,while True调用step,step执行query结果,query将模型消息追加到历史。

不必一次读懂全部代码,先找三处:query 使用 self.messages 请求模型;随后 add_messages 保存模型输出;step 把 query 的返回交给 execute_actions。图中 execute_actions 的说明写着追加观察消息。下一轮 query 因而有机会读取更新后的历史。课件省略了完整的停止与异常路径,不能把这段 while True 单独当作可直接运行、永远不会失控的程序。

为了把这个边界讲清楚,本课的控制逻辑可以写成下面的伪代码。它是教学补充,不是原课源码的逐字复制:

初始化 messages = [规则, 用户任务]
模型调用数 = 0

只要模型调用数还没到上限:
    生成 = 请求模型(messages, 工具说明)
    模型调用数 += 1
    保存生成的 assistant 消息

    如果没有工具请求:
        展示生成的最终回答,结束

    逐个校验并执行工具请求
    为每个请求追加对应的 tool 结果或明确错误
    检查消息配对完整,再进入下一轮

如果因为上限退出:
    报告“预算已用完,任务可能未完成”

回到实验,把“模型调用上限”从 2 改成 1。第一次请求和工具执行仍然会发生,第二次消息也能被组装出来,但第二次模型调用不能提交。上限让程序停住,却不会替模型生成那句 36 元的回答。

本课选择在下一次模型调用之前检查预算;其他系统也可能在执行工具之前检查总费用、时间或动作预算。检查位置不同,已经发生的工作也不同,不能把这些边界混在一起。实际系统还需要超时、取消和异常恢复,这里只把调用次数这个停止条件看清。

07 / 合上实验,你能把四条消息放回正确位置吗?

问题 1 · 数调用,不数消息

输入里有 system、user、assistant、tool 四条消息,是否表示调用了模型四次?

核对解释

不是。它们可以共同构成第二次调用的一份输入。消息条数和模型请求次数不是同一个量。本例共两次模型生成、一次工具执行。

问题 2 · 找到每个数的来源

用户要 4 盒,工具返回库存 5、单价 12。第二次输入应该保留哪些信息,结论是什么?

核对解释

保留规则、包含“4盒”的原任务、查询请求及匹配的结果。5 ≥ 4,所以库存够;4 × 12 = 48 元。“4”来自 user,“5”和“12”来自 tool。仍然没有创建订单或扣减库存。

问题 3 · 识别停在哪一层

日志里工具读取成功,但模型调用数只有 1,页面显示预算耗尽。这是“模型算不出价格”吗?

核对解释

还不能这样判断。第二次模型生成根本没有发生,停止发生在宿主控制层。应先核对调用预算与提交记录,而不是直接归因于模型能力。

问题 4 · 为什么保留 assistant 请求

只把库存 JSON 接在用户问题后面,完全丢掉原来的工具请求,有什么信息会丢失?

核对解释

会丢失这条观察对应哪个动作的记录;在结构化工具协议里,还可能破坏请求与结果的配对。普通文本提示也能包含数据,但那不是同一份可追踪的工具调用历史。下一课将用两次查询进一步看错配的后果。

原课对照:这一节对应哪些页面?

本节对应两个官方讲次的一个知识主题,不等于把官方 Lecture 2 整讲压成一页。课件中的天气、文件与代码案例保留在原图中;库存例子、条件实验和中文说明由本项目编写。

原图从本地归档的官方 PDF 页面渲染,页面序号按 PDF 从 1 起算;点击原图可放大,并可打开官方 PDF 对应页。原图内容与中文改编分开展示。来源核对日期:2026-10-25。

下一个问题

两个工具都返回结果,模型怎么知道哪个结果属于哪个请求?

本课只有一次查询,call_stock_1 很容易对上。下一课同时查询红笔和蓝笔,把两个返回值交换,再看为什么不能仅靠消息到达顺序判断归属。