Lab 1:让 Agent 第一次开口 — 实验任务¶
这次的任务顺序是从协议理解走到真实源码:先看消息结构,再补全 query-lab1.ts。
Lab 1 的任务分成 7 个步骤。前半段帮助你理解消息协议,后半段才进入代码实现。
1 2 3 4 5 6 7 8 | |
Step 1:看一段真实对话¶
先阅读这段对话,不急着写代码。每条消息下面有思考注释,帮助你理解它为什么存在:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | |
请先回答三个自我解释问题:
- 第 2 条消息为什么是
assistant?
参考答案
因为这是 LLM 生成的回复。在 Messages API 中,LLM 的输出永远是 assistant role。即使回复里包含 tool_use,它仍然是 assistant 发出的。
- 第 3 条消息为什么看起来像工具结果,但
role仍然是user?
参考答案
Anthropic Messages API 只有 user 和 assistant 两个 role。工具结果不是 LLM 自己说的,而是外部世界给模型的新输入,所以它必须作为 user 消息回到对话历史里。
- 第 2 条里的
tool_use和第 3 条里的tool_result靠什么字段对应起来?
参考答案
靠 id 字段。第 2 条的 tool_use.id 和第 3 条的 tool_result.tool_use_id 必须相同。这样即使一条消息里调用多个工具,LLM 也能把每个结果和对应意图配对。
Step 2:标注 role 和 content block¶
这一部分先用平台里的小题确认你真的分清了 role 和 block。答错不会锁住题目;看完反馈后可以重新选择。
Level 1:识别¶
::::quiz-single[下面哪条消息包含工具调用?]{id="lab1-tool-use-message" answer="B" explanation="对,tool_use 是 assistant 发出的工具调用意图。"}
- A) { role: "user", content: "读取文件" }
- B) { role: "assistant", content: [{ type: "tool_use", name: "read_file", ... }] }
- C) { role: "user", content: [{ type: "tool_result", content: "..." }] }
:::quiz-feedback[A]
这只是用户文本消息,还没有出现工具调用意图。
:::
:::quiz-feedback[C]
这是工具执行后的结果,不是工具调用本身。调用意图叫 tool_use,结果叫 tool_result。
:::
::::
::::quiz-single[下面哪个 content block 表示工具执行后的结果?]{id="lab1-tool-result-block" answer="C" explanation="对,tool_result 是 Harness 执行工具后构造的结果 block。"}
- A) { type: "text", text: "读取完成" }
- B) { type: "tool_use", name: "read_file", input: { path: "README.md" } }
- C) { type: "tool_result", tool_use_id: "toolu_01", content: "文件内容..." }
:::quiz-feedback[A]
text block 只是自然语言文字,不代表工具执行结果。
:::
:::quiz-feedback[B]
tool_use 是 assistant 发出的调用意图;工具结果必须是 tool_result。
:::
::::
Level 2:推理¶
::::quiz-single[Assistant 刚发出一条包含 tool_use 的消息,下一条消息应该是什么?]{id="lab1-after-tool-use" answer="B" explanation="对。Harness 执行工具后,要把结果包装成 user 消息里的 tool_result,再发回给 LLM。"}
- A) assistant: 我正在等待工具结果
- B) user: tool_result,包含工具执行结果
- C) user: 好的,继续
- D) tool: tool_result,包含工具执行结果
:::quiz-feedback[A]
assistant 不能自己执行工具,也不能自己给出工具结果。
:::
:::quiz-feedback[C]
普通 user 文本无法和前面的 tool_use.id 配对,模型不知道这是哪个工具的结果。
:::
:::quiz-feedback[D]
Anthropic Messages API 没有 tool role。工具结果也要放在 role: "user" 的消息里。
:::
::::
看这段不完整的消息序列,[?] 处应该填什么?
1 2 3 4 5 6 7 8 | |
::::quiz-single[占位处应该填哪个消息?]{id="lab1-complete-tool-result-message" answer="B" explanation="对。工具结果必须是 role: user 的消息,并用 tool_use_id 对应上面的 toolu_02。"}
- A) { role: "assistant", content: [{ type: "tool_result", ... }] }
- B) { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_02", content: "..." }] }
- C) { role: "user", content: "这是文件内容:..." }
- D) { role: "tool", content: [{ type: "tool_result", tool_use_id: "toolu_02", content: "..." }] }
:::quiz-feedback[A]
tool_result 不是 assistant 自己发出的。assistant 发出的是 tool_use 意图。
:::
:::quiz-feedback[C]
普通字符串没有 tool_use_id,无法和前面的 toolu_02 配对。
:::
:::quiz-feedback[D]
这里最容易混淆:Anthropic 没有 tool role。工具结果是给模型的新输入,所以放在 user role。
:::
::::
Level 3:预测与 debug¶
::::quiz-single[如果请求里没有 tools 参数,assistant 收到“请创建 hello.js”后最可能怎样?]{id="lab1-no-tools-behavior" answer="B" explanation="对。tools 是可选参数,没有工具定义时,模型没有任何工具可调用。"}
- A) 报错 missing tools parameter
- B) 用文字解释怎么创建,但不会真的调用工具
- C) 自动使用内置 write_file 工具
- D) 返回 stop_reason: tool_use,但没有 tool_use block
:::quiz-feedback[A]
没有 tools 参数不是协议错误,只是模型没有工具可以使用。
:::
:::quiz-feedback[C]
工具不是 API 内置的。你必须在 tools 参数里注册,模型才可能调用。
:::
:::quiz-feedback[D]
stop_reason: "tool_use" 必须对应实际的 tool_use block。
:::
::::
1 2 3 4 5 6 7 8 9 10 11 | |
::::quiz-single[这段消息序列里最严重的错误是什么?]{id="lab1-debug-tool-result-role" answer="C" explanation="对。tool_result 所在消息必须是 role: user。assistant 只发 tool_use 意图,不能自己发 tool_result。"}
- A) 第 1 条消息应该是 system role
- B) 第 2 条消息不应该同时有 text 和 tool_use
- C) 第 3 条消息的 role 应该是 user 而不是 assistant
- D) 第 4 条消息不应该存在
:::quiz-feedback[A]
普通用户请求就是 user role,不需要 system role。
:::
:::quiz-feedback[B]
assistant 可以在同一条消息里同时说一句话并发出 tool_use。
:::
:::quiz-feedback[D]
第 4 条可以存在。模型收到工具结果后继续回复,完成对话闭环。
:::
::::
Step 2.5:补全不完整 JSON¶
现在把空位和字段名对应起来。这里不提前给答案;选错后平台会告诉你错在什么概念上。
::::quiz-code[assistant 想调用 read_file 时,两个空应该怎么填?]{id="lab1-code-tool-use-message" answer="B" explanation="对。assistant 发出工具调用意图,block 类型是 tool_use,工具名来自 tools 定义里的 read_file。"}
1 2 3 4 5 6 7 8 9 10 11 12 | |
type: "tool";name: "read_file"
- B) type: "tool_use";name: "read_file"
- C) type: "tool_use";name: "create_file"
- D) type: "function_call";name: "read_file"
:::quiz-feedback[A]
tool 不是合法的 content block 类型。Anthropic 这里叫 tool_use。
:::
:::quiz-feedback[C]
block 类型对了,但工具名错了。工具名称由 tools 参数定义,这里注册的是 read_file。
:::
:::quiz-feedback[D]
如果你用过其他 API,可能见过 function_call;但 Anthropic Messages API 这里使用 tool_use。
:::
::::
::::quiz-code[工具执行完成后,这段 tool_result 消息的两个空应该怎么填?]{id="lab1-code-tool-result-message" answer="B" explanation="对。工具结果作为下一轮模型输入发送,所以 role 是 user;block 类型必须是 tool_result。"}
1 2 3 4 5 6 7 8 9 10 | |
role: "assistant";type: "tool_result"
- B) role: "user";type: "tool_result"
- C) role: "tool";type: "result"
- D) role: "user";type: "tool_response"
:::quiz-feedback[A]
tool_result 不是 assistant 自己说的。它是 Harness 执行工具后给模型的新输入。
:::
:::quiz-feedback[C]
Anthropic Messages API 没有 tool role,result 也不是合法 block 类型。
:::
:::quiz-feedback[D]
role 对了,但 block 类型不叫 tool_response 或 function_result,而是 tool_result。
:::
::::
Step 3:进入真实源码变体¶
右侧打开 src/query-lab1.ts。这不是独立练习文件,而是 claude-code-diy/src/query.ts 的 Lab 1 变体。
提交时,平台会把这个文件注入容器,然后运行:
1 | |
构建脚本会自动把 query-lab1.ts 编译后的产物替换到 dist/src/query.js。也就是说,你改的是一个真实 TUI 会调用的入口,而不是浏览器里的模拟代码。
Lab 1 的目标很窄:保留真实 TUI 和 LLM 流式回复路径,但不接工具系统、不进入 Agent Loop。
Step 4:补全 query-lab1.ts 的 4 个 TODO¶
在右侧找到 TODO-Lab1-1 到 TODO-Lab1-4。它们对应 Agent 第一次开口所需的最短路径:
| TODO | 你要完成什么 | 为什么重要 |
|---|---|---|
TODO-Lab1-1 |
用 appendSystemContext() 和 asSystemPrompt() 构建完整 system prompt |
LLM 需要知道当前 Agent 的角色和上下文 |
TODO-Lab1-2 |
用 getMessagesAfterCompactBoundary() 取要发送的消息 |
真实 Claude Code 会处理压缩边界,Lab 1 要兼容这条数据流 |
TODO-Lab1-3 |
调用 deps.callModel(),并把流式返回的 message yield 给 TUI |
这是“Agent 能说话”的核心 |
TODO-Lab1-4 |
返回 { reason: 'completed' } |
告诉 TUI 本轮对话已经结束 |
重点观察这一段:
1 2 3 | |
deps.callModel() 负责和底层 LLM 通信;yield message 负责把模型输出交给 TUI 显示。少了前者,Agent 没有语言来源;少了后者,TUI 看不到回复。
Lab 1 不给模型工具
query-lab1.ts 会把 tools 和 mcpTools 置为空。这样模型可以回复文字,但不会真正读取文件、写文件或执行命令。
Step 5:构建 Lab 1¶
点击右侧底部的“提交代码”。平台会保存当前 src/query-lab1.ts,注入实验容器,并运行 node build.mjs --lab 1。
你应该在终端日志里看到类似输出:
1 2 3 | |
如果构建失败,优先看错误指向的 TODO 区域:
| 错误线索 | 常见原因 |
|---|---|
fullSystemPrompt 相关 |
TODO 1 没有构建完整 system prompt |
messagesForQuery 相关 |
TODO 2 没有生成要发送给 LLM 的消息数组 |
deps.callModel 参数错误 |
TODO 3 的参数对象缺字段或括号没闭合 |
not all code paths return |
TODO 4 没有返回完成状态 |
Step 6:启动 TUI 观察能力边界¶
构建成功后,在下方终端运行:
1 | |
然后输入几条 prompt 观察:
| 输入 | 你应该观察到什么 | 为什么 |
|---|---|---|
你好,请用一句话说明你现在能做什么 |
Agent 能回复文字 | 语言通道已经接通 |
请读取 README.md 第一行 |
Agent 还不能真正读取文件 | Lab 1 没有工具执行能力 |
请创建 hello.js |
Agent 可能解释怎么做,但不会真的写文件 | write_file 要到 Lab 2 才接上 |
| 连续问两轮相关问题 | Agent 能利用当前 TUI 的消息输入路径继续对话 | query() 会收到 TUI 传入的消息历史 |
Step 7:对照消息协议复盘¶
回到前面看到的 tool_use / tool_result 协议。现在你应该能解释两件事:
- Lab 1 为什么只需要把
user消息发给 LLM,再把assistant回复yield给 TUI。 - Lab 1 为什么还不会出现真正的
tool_result:因为 Harness 还没有执行工具,也没有把工具结果作为role: "user"的新消息塞回历史。
完成标志
你完成 Lab 1 后,应该能清楚解释:Agent 为什么能说话,为什么还不能读文件,以及为什么工具结果会作为 role: "user" 回到消息历史里。