一次对话的完整旅程
把前 4 天串起来。跟着一句真实需求,看代码怎么在 UI → 循环 → API → 工具各层之间流转,最后把结果显示回屏幕。这是第 1 周的收官——把脑(D03)、脸(D04)、启动(D02)拼成一次完整对话,第 2 周再逐块钻源码。
一个真实场景
设想你在终端里输入:
› 帮我看看 utils.ts 里的 fetchData,给它加上失败重试
接下来发生的事,会用到前 4 天学的每一块:Day 02 的启动(界面已经跑起来了)、Day 04 的 UI(输入框接收、消息渲染)、Day 03 的循环(turn 转起来)。今天我们把它走成一条端到端的旅程,标清每一步在哪个文件。
这个任务模型大概会分两轮:第一轮——"我得先读这个文件"(调 Read 工具);第二轮——看完内容后"我来改它"(调 Edit 工具);然后收尾。正好演示循环转两圈。
tool_use 块,形如:{ type:"tool_use", name:"Read", input:{ file_path:".../utils.ts" } }循环层一看到这个块 → 知道"它要动手了" → 去执行 Read → 把文件内容作为
tool_result 回填。整趟旅程就是这种"块"在各层之间传来传去。端到端旅程动画
下面每一步会依次高亮,颜色代表它属于哪一层(🔵UI / 🟠循环 / 🟣API / 🟢工具):
第 1 步:输入 → 提交
你敲完回车。Day 04 讲过 PromptInput 的 useTextInput 判定"普通 Enter = 提交"(useTextInput.ts:248),把文本上抛给 REPL 的 onSubmit,进而 onQuery(REPL.tsx:3614)。关键一步是并发守卫:
// REPL.tsx:3614 —— onQuery 先原子占位
if (!queryGuard.tryStart()) {
enqueue(input) // 上一个查询还在跑?把这条排队,稍后消费
return
}
// 抢到锁,才真正发起查询
QueryGuard.tryStart() 原子地"抢锁"——抢到才开始,没抢到就把输入塞进队列(useQueueProcessor),等当前 turn 结束后再自动消费。这保证了"一次只跑一个 turn,但你的输入不会丢"。第一轮:模型要读文件
REPL 用 for await 消费 query(...) 的产出(REPL.tsx:3510):
// REPL.tsx:3510
for await (const event of query({ messages, systemPrompt, canUseTool, ... }))
onQueryEvent(event); // 每个事件更新 UI
循环层(query.ts)第一轮:压缩上下文 → 调模型。模型看到你的需求 + 可用工具清单,决定"我得先读 utils.ts",于是流式返回里带一个 tool_use 块(工具名 Read、参数 {file_path: ".../utils.ts"})。
- API 层(
claude.ts:2075)把流式增量拼成完整 assistant 消息,同时透传 stream_event(Day 04 讲的"边流边显")。 - 循环层检测到 tool_use 块 →
needsFollowUp = true(query.ts:1090)。 - UI 层同时把"● 我先看一下这个文件"这段文字流式显示出来(Day 04 的 streamingText)。
执行工具 + 结果回填
因为 needsFollowUp,循环层跑工具(query.ts:1671 的 runTools)。Read 是只读工具,权限检查大概率直接放行(在工作目录内,Day 09 讲),读到文件内容。
然后关键的回填(Day 03 讲过)——工具结果变成一条 user 消息,拼进下一轮(query.ts:2044):
messages = 历史.concat(
assistantMessages, // 模型说"我要 Read"
toolResults // "这是 utils.ts 的内容:export function fetchData()..."
)
// continue → 第二轮 callModel,模型现在"看到了"文件内容
第二轮:模型基于文件内容,决定"我来改",返回一个 Edit 的 tool_use(old_string=原函数,new_string=带重试的版本)。这次 Edit 是写操作,会触发权限流(Day 09)——UI 弹出一张权限卡问你"允许修改 utils.ts 吗?"。你批准后,Edit 执行、结果回填,进第三轮。
acceptEdits 模式让编辑自动放行。tool_use("我想调 Edit,参数是这些"),真正落地改文件的是本地工具代码,而且写操作还要先过权限这道闸。模型出主意,工具才动手。👶 小白:一句需求而已,为啥要来回调三轮模型这么费劲?一次说清不行吗?
👨🏫 老师:因为模型一开始没看过 utils.ts 的内容,没法凭空改对。它必须"先取文件看一眼(第 1 轮)→ 看懂了再改(第 2 轮)→ 确认改好收尾(第 3 轮)"。这正是 Day 03 那个循环存在的意义:让模型能"看着现场"一步步干,而不是闭眼一次给完。
收尾与显示
第三轮:模型改完了,这次只返回文字"✓ 已给 fetchData 加上 3 次重试,用指数退避",没有 tool_use 块。于是 !needsFollowUp,循环走停止路径 return {reason:'completed'}(query.ts:1647)。
回到会话层,QueryEngine 抽取最后一条 assistant 文本,yield 一个 type:'result'(QueryEngine.ts:1194)。UI 层的 onQueryEvent 一路把这些消息 setMessages(old => [...old, msg])(REPL.tsx:3223)追加进列表,Day 04 的 Messages 组件把它们渲染出来。queryGuard 释放锁,如果刚才有排队的输入,现在自动消费。
第 1 周收官
你现在已经能把"敲回车 → 看到结果"整条链讲出来了。这是全项目的骨架。接下来三周是往骨架上填肉:
- 第 2 周(核心引擎):QueryEngine 深入、工具系统、内置工具、权限、上下文管理——把 Day 03/05 里"一笔带过"的循环细节和工具执行钻透。
- 第 3 周(扩展能力):Slash 命令、MCP、Skills、Hooks、子代理——Agent 怎么被外部能力增强。
- 第 4 周(平台化):会话记忆、配置模型成本、TUI 深入、远程控制、构建收官。
🧠 第 1 周自测
- 能不能不看图,自己把"一次对话旅程"的 11 步大致讲出来?
- 循环层为什么是中枢?UI 只在哪两处出现?
- 并发守卫 QueryGuard 解决什么?
- 为什么读文件不弹窗、改文件弹窗?
- turn 结束的判据是什么?(模型不再返回 tool_use 块)
✋ 动手:亲自跟一次真实旅程
# 用 dev 模式跑起来,真的发一句需求,观察工具行
bun run dev
# 输入:读一下 package.json 告诉我版本号
# 观察:● Read(package.json) 工具行 → 回答里出现版本号
# 这就是循环转了一圈:调模型→要 Read→执行→回填→收尾
# 想看代码怎么走,在这几个点打 console.error 观察:
# REPL.tsx:3510 (for await)、query.ts:1090 (检测 tool_use)、query.ts:1647 (completed)