Day 05 / 共 20 天 · 第 1 周收官

一次对话的完整旅程

把前 4 天串起来。跟着一句真实需求,看代码怎么在 UI → 循环 → API → 工具各层之间流转,最后把结果显示回屏幕。这是第 1 周的收官——把脑(D03)、脸(D04)、启动(D02)拼成一次完整对话,第 2 周再逐块钻源码。

📍 你在整门课的位置 · 第 1 周 建立心智(收官)
D01 全景地图 D02 启动链 D03 Agent 循环 D04 Ink UI D05 完整旅程· W2 核心引擎 →
💡 换个视角:这次"你"就是那句需求(第一人称请求之旅) 今天试着把自己想象成那句"帮我给 fetchData 加重试"的需求单,跟着自己在公司里流转:你先被前台(UI 输入框)收下、登记 → 交给项目组长(循环层)统筹 → 组长打电话问专家顾问(大模型 API)"这活怎么干" → 顾问说"先去档案室取份文件",组长就派跑腿(工具)去取 → 取回来再问顾问……如此往返,直到顾问说"齐活了",组长把成果交回前台展示给你。循环层(组长)是全程中枢,前台(UI)只在你进门和拿结果时露两次面。下面就带你走完这趟。
L01

一个真实场景

设想你在终端里输入:

› 帮我看看 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 回填。整趟旅程就是这种"块"在各层之间传来传去。
L02

端到端旅程动画

下面每一步会依次高亮,颜色代表它属于哪一层(🔵UI / 🟠循环 / 🟣API / 🟢工具):

1
输入框接收 & 提交PromptInput → onSubmit → REPL.onQuery (REPL.tsx:3614)
UI
2
QueryEngine 起一个 turnsubmitMessage → for await query() (QueryEngine.ts:688)
循环
3
queryLoop 压缩上下文 + 调模型while(true) → deps.callModel (query.ts:460/899)
循环
4
API 流式:模型说"我要 Read"queryModel SSE → tool_use block (claude.ts:2075)
API
5
检测到 tool_use → needsFollowUpquery.ts:1090
循环
6
执行 Read 工具(含权限检查)runTools → Read.call (Day 07/08)
工具
7
结果拼回,进第二轮messages.concat(assistant, toolResults) (query.ts:2044)
循环
8
第二轮:模型说"我要 Edit"再次 callModel → tool_use
API
9
执行 Edit(弹权限卡等你批准)useCanUseTool → 权限弹窗 (Day 09)
工具
10
第三轮:模型只给文字,不再要工具!needsFollowUp → return completed (query.ts:1647)
循环
11
结果渲染到屏幕onQueryEvent → setMessages → Messages 列表 (REPL.tsx:3223)
UI
看这条旅程你会发现:循环层(橙)是绝对的中枢——它反复在"调 API(紫)"和"跑工具(绿)"之间来回,UI(蓝)只在头尾出现(接收输入、显示结果)。这正是 Day 03 那个"圈"的展开:转了三圈(读→改→收尾),每圈一次 API + 可能一次工具。
一张"需求单"在四个部门之间流转 🔵 前台 UI收单 / 展示 🟠 组长 循环中枢 · 反复调度 🟣 顾问 API模型出主意 🟢 跑腿 工具读写 / 跑命令 ①收单 ⑪ 交回结果展示 组长与顾问/跑腿之间往返多次(实线去、虚线回),直到顾问说"齐活"
图注:橙色"组长"是中枢,反复往返于紫色"顾问"和绿色"跑腿";蓝色"前台"只在开头收单、结尾展示各出现一次。
L03

第 1 步:输入 → 提交

🤔 痛点:组长还在忙,你又塞了一张单子怎么办? Agent 正干着活(turn 没结束),你手一痒又敲了一句回车。要是直接又起一个 turn,两个 turn 抢同一份对话历史,立刻乱套。得有个规矩:一次只干一单,多来的先排队。

你敲完回车。Day 04 讲过 PromptInputuseTextInput 判定"普通 Enter = 提交"(useTextInput.ts:248),把文本上抛给 REPL 的 onSubmit,进而 onQueryREPL.tsx:3614)。关键一步是并发守卫

// REPL.tsx:3614 —— onQuery 先原子占位
if (!queryGuard.tryStart()) {
  enqueue(input)     // 上一个查询还在跑?把这条排队,稍后消费
  return
}
// 抢到锁,才真正发起查询
为什么要并发守卫? 用户可能在 Agent 还在干活时又敲了一句。不能同时跑两个 turn(会乱套)。QueryGuard.tryStart() 原子地"抢锁"——抢到才开始,没抢到就把输入塞进队列(useQueueProcessor),等当前 turn 结束后再自动消费。这保证了"一次只跑一个 turn,但你的输入不会丢"。
L04

第一轮:模型要读文件

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 = truequery.ts:1090)。
  • UI 层同时把"● 我先看一下这个文件"这段文字流式显示出来(Day 04 的 streamingText)。
L05

执行工具 + 结果回填

因为 needsFollowUp,循环层跑工具(query.ts:1671runTools)。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 执行、结果回填,进第三轮。

为什么读不弹窗、改要弹窗? 因为读文件(在工作目录内)风险低、默认放行;而写文件会改变你的代码,风险高,默认要你确认。这个"按风险区别对待"的权限系统就是 Day 09 的主题。你也能配置成 acceptEdits 模式让编辑自动放行。
⚠️ 小白常误以为:"模型自己就把文件改了。" 其实模型动不了你的电脑一根手指——它只能"说"出一个 tool_use("我想调 Edit,参数是这些"),真正落地改文件的是本地工具代码,而且写操作还要先过权限这道闸。模型出主意,工具才动手。

👶 小白:一句需求而已,为啥要来回调三轮模型这么费劲?一次说清不行吗?

👨‍🏫 老师:因为模型一开始没看过 utils.ts 的内容,没法凭空改对。它必须"先取文件看一眼(第 1 轮)→ 看懂了再改(第 2 轮)→ 确认改好收尾(第 3 轮)"。这正是 Day 03 那个循环存在的意义:让模型能"看着现场"一步步干,而不是闭眼一次给完。

L06

收尾与显示

第三轮:模型改完了,这次只返回文字"✓ 已给 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 释放锁,如果刚才有排队的输入,现在自动消费。

一次 turn 就此结束。你看到的最终画面:你的问题、几条"● Read(utils.ts) / Edit(utils.ts)"的工具行、以及最后的完成总结。背后是循环转了三圈、两次工具执行、一次权限确认、全程 token 和成本被自动记账(Day 17)。这就是 Claude Code 干一件事的完整机理。
L07

第 1 周收官

Day 01 项目全景:逆向 CLI、Bun/TS/Ink、模块地图
Day 02 启动链:cli.tsx fast-path → main/init → Ink 挂载
Day 03 Agent 循环:三层结构、turn、工具回填、停止
Day 04 Ink UI:组件树、消息渲染、流式、ref 真值源

你现在已经能把"敲回车 → 看到结果"整条链讲出来了。这是全项目的骨架。接下来三周是往骨架上填肉:

  • 第 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)
第 2 周预告 · Day 06:从宏观进入微观。Day 06 深入 QueryEngine 源码——那个驱动 turn 的生成器到底怎么写的、状态怎么跨 turn 保持、usage/成本怎么统计。
← Day 04 Ink 入门 Day 06 · QueryEngine 深入 →