26 · Multi-turn Session
Stage Hello Coding Agent · v26-session

第二十五章,我们把 Coding Agent 套上了产品壳——hello "帮我修复这个项目" 一条命令,--dir 打开任意项目,--chat 进多轮对话。修 bug 的转录里,它先 read、再 edit、最后 bash 跑 npm test 全过才交差。
但兄弟们,回头看看 --chat 的实现,那其实是 ch19 时代的裸奔多轮:一个局部 history: Message[] 数组,每轮手动拼 [...history, userMessage(prompt)] 再塞进 runtime.run()。它没有身份、没有专属的上下文对象、甚至连 system prompt 都没带。这一章,我们把「多轮对话」正式化为一个一等公民——Session(会话)。
一、上一版存在什么问题?
回看 ch22 引入、ch25 继续沿用的 --chat:
- 多轮靠一个局部数组硬拼:
history只是chat()函数里的一个Message[],每轮[...history, userMessage(prompt)]重新组装——上下文不是被「持有」的,而是被「传来传去」的; - 每一轮都从零造一个上下文:
runtime.run()内部每次new AgentContext(request.messages)——「这一轮的 AgentContext」和「上一轮的 AgentContext」没有继承关系,只是消息列表长得像; - 对话没有身份:Session 是谁?没有
id,没有能指向「这场对话」的句柄——你无法说「继续昨天的对话」,因为压根没有一个东西叫「昨天的对话」; - 连 system prompt 都没带:
chat()直接runtime.run({ messages: history }),ch24 立的方法论(先观察、再修改、再验证)在--chat里根本没生效; - 上下文和对话生命周期绑死在 CLI 函数里:
history随函数结束而消亡,拿不出来、存不下、续不上——下一章想落盘.sessions/,连个可序列化的对象都没有。
一句话:上一版的多轮是「行为上的多轮」,不是「结构上的多轮」——对话没有身份、上下文没有归属、历史存不下。它是个循环,不是个产品。
二、本篇解决什么问题?
- 引入 Session 抽象:
Session { id, context }——一场对话 = 一个唯一 id + 一个被持有的 AgentContext; - 上下文跨轮继承:
runtime.runContext(context)让 Runtime 在同一个 AgentContext 上连续运行,每轮往同一个上下文追加消息,而不是从零重建; - 补上 system prompt:
chat()创建 Session 时就用systemMessage(SYSTEM_PROMPT)播种,ch24 的方法论第一次进入多轮对话; - 对话有了身份:启动时打印
Session : <id>,你能指着这串 id 说「就是这场对话」——这正是下一章.sessions/落盘、再下一章--resume续跑的钩子; - 为持久化铺路:
Session.snapshot()一次性导出{ id, context },restore()原样恢复——「能导出」是「能存盘」的前提。
核心心智模型:
Runtime 管「怎么跑一轮」,Session 管「这场对话是什么」。多轮不是把消息数组拼长,而是让同一个 AgentContext 在一场对话里持续生长——上下文是 Session 的财产,不是每次 run 的临时工。
解决完上面五件事,把线串一下:上一版「多轮靠局部数组、上下文每次重建、对话没身份、没 system prompt」这些遗留问题 → 这一章用「Session + runContext」解决 → 接下来看一个带 id 的 Session 怎么在两轮对话里连续修 bug、加功能。
解决之后,我们收获了什么?
- 多轮有了结构:
Session是对话的一等公民——id给身份,context给归属,一场对话就是一个对象; - 上下文真的被继承了:第二轮开跑时,上一轮修好的文件内容、跑过的测试、得出的结论全在同一个 AgentContext 里;
- 方法论生效了:
--chat终于带着 ch24 的 system prompt 上阵——多轮对话里的 Coding Agent 也讲「先观察、再修改、再验证」; - 对话可以被指认:
Session : <uuid>打印出来,「这场对话」有了可指向的句柄; - 能导出就能落盘:
snapshot()/restore()就位,为 ch27.sessions/持久化和 ch28--resume铺好了路。
一句话收个尾:遗留的「多轮靠数组硬拼、上下文每次重建、对话没身份」问题被这一章的 Session 解决掉,换来的则是「一场带 id、上下文持续生长的对话」——下一章就把它存进磁盘。
三、先看最终效果
还是 examples/stage-3/25-cli/ 这个带 bug 的小项目(factorial 里 n - 2 写错了)。这一章我们用 hello --chat 开一场两轮对话:
- 第一轮:修复
factorial的 bug; - 第二轮:接着加一个
divide函数和测试——注意,第二轮没让模型重新读一遍项目历史,它直接接上第一轮的上下文干活。
启动后第一行就是这场对话的身份:
Hello Harness v1.0 · 流式多轮对话(输入 exit 退出,Ctrl+C 取消本轮)
Session : 5335818f-843a-4b1b-a0fd-55bc75093dc6第一轮的真实转录(精简掉中间的思考流):
你 > 帮我修复这个项目里的 bug:src/calc.mjs 的 factorial 函数结果不对,请先观察、再修改、最后运行 npm test 验证
[run:start ] Run ID : f19a3b01-3d6d-4adb-bd99-ececf967347a
[run:start ] Input : 帮我修复这个项目里的 bug:src/calc.mjs 的 factorial 函数结果不对,请先观察、再修改、最后运行 npm test 验证
[model:start] 思考中 …
[model:end ] 调用工具:read, bash · 1182 in / 146 out · 2360ms
Step 1 · model → 调用工具:read, bash
[tool:start] read({"path":"src/calc.mjs"})
[tool:end ] → "..." · 3ms
Step 2 · tool → read({"path":"src/calc.mjs"}) = "export function add(a, b) {\n return a + b;\n}\n\nexport function factorial(n) {\n if (n <= 1) return 1;\n return n * factorial(n - 2);\n}"
Step 3 · tool → bash({"command":"find . -type f -name \"*.mjs\" -o -name \"*.js\" -o -name \"package.json\" | head -20"}) = {...}
[model:start] 思考中 …
## 省略过程推理
找到了!`factorial` 函数递归时错误地减了 2,应该减 1。现在修复:
[model:end ] 调用工具:edit · 1857 in / 195 out · 4177ms
Step 7 · model → 调用工具:edit
[tool:start] edit({"path":"src/calc.mjs","oldString":"return n * factorial(n - 2);","newString":"return n * factorial(n - 1);"})
[tool:end ] → "已替换 1 处:return n * factorial(n - 2); → return n * factorial(n - 1);(src/calc.mjs)" · 4ms
Step 9 · model → 调用工具:bash
[tool:start] bash({"command":"npm test"})
Step 11 · model → 完成回答
Step 12 · finish → finished
[run:end ] completed (finished) · 15433ms
Answer : 全部 3 个测试通过 ✅
**Bug 原因**:`factorial` 函数递归调用时写的是 `factorial(n - 2)`,导致跳过了中间的数(例如 `factorial(5)` 算成了 `5 * 3 * 1 = 15`)。改为 `factorial(n - 1)` 后恢复正常。
Steps : 5 轮 · 13 条消息 · 12 步 · 15433ms
Tokens : 8829 in / 702 out
Status : completed (finished)第二轮——同一个 Session,接着修:
你 > 很好,现在再帮我加一个 divide 函数,并补充一条测试
[run:start ] Run ID : 5335818f-843a-4b1b-a0fd-55bc75093dc6
Step 2 · tool → read({"path":"src/calc.mjs"}) = "... return n * factorial(n - 1); ..." // 第一轮的修复还在
Step 4 · model → 调用工具:edit(追加 divide 函数)
Step 5 · tool → [tool] 在 src/calc.mjs 中未找到 oldString // CRLF 换行没匹配上,没关系,方法论教它重试
Step 12 · tool → write({"path":"src/calc.mjs","content":"... export function divide(a, b) { ... }"}) = "已写入"
Step 14 · tool → edit({"path":"test/calc.test.mjs", ...}) = "已替换 1 处"(导入 divide)
Step 16 · tool → edit({"path":"test/calc.test.mjs", ...}) = "已替换 1 处"(新增测试)
Step 18 · tool → bash({"command":"npm test"}) → pass 5 / fail 0
Status : completed (finished)注意第二轮的两个细节,这正是 Session 的功劳:
- 它记得第一轮把
n - 2改成了n - 1——没让用户重讲一遍项目背景; - 前面的测试用例,在这一轮中也没忘记。
四、架构变化
这一章加了一层薄薄的「会话层」,放在 Runtime 之上:
CLI(chat / runAgentDemo)
│
┌─────────┴──────────┐
▼ ▼
Session AgentRuntime
(id + context) (run / runContext)
│ │
└─────── 同一个 AgentContext ──────┘
│
Model / Tools关键变化:AgentRuntime 新增 runContext(context)——在外部传入的 AgentContext 上运行一整轮;旧的 run(request) 变成它的薄包装(从 request.messages 造一个临时上下文)。上下文不再由 Runtime 内部每次新建,而是可以由 Session 持有、跨轮复用。
目录结构:
src/
agent/
runtime.ts # + runContext(context):在指定上下文上跑一轮
session/
session.ts # + Session:id + AgentContext + turn() + snapshot()/restore()
cli/
chat.ts # 改用 Session,补 system prompt,打印 Session id
index.ts # chat() 传入 SYSTEM_PROMPT五、核心抽象
这一章只新增一个核心概念——Session:
interface Session {
id: string; // 一场对话的唯一身份
context: AgentContext; // 这场对话持有的、持续生长的上下文
}三个设计要点:
- id 是身份的锚点:
randomUUID()生成,对话可被指认、可被引用——ch27 拿它当文件名,ch28 拿它当 resume 参数; - context 是财产的归属:上下文从「run 的入参」变成「Session 的成员」——多轮不靠数组拼接,靠同一个 AgentContext 连续追加消息;
- turn() 是对话的节拍:
session.turn(runtime, prompt)= 往 context 加一条 user 消息 → 在 context 上跑一轮 → 返回本轮AgentRun。一轮对话一个节拍,节拍之间上下文保持生长。
同时给 AgentContext 补齐一对「快照」能力(其实 ch19 就有):
snapshot()→{ messages }:把上下文一次性导出来;restore(snapshot)→ 原样恢复。
为什么现在就要 snapshot? 因为「能导出」是「能存盘」的前提——Session 把
{ id, context }抱在怀里,下一步 ch27 只需要把它JSON.stringify进.sessions/session-id.json就完成了持久化。这一章做的所有设计,都是在给下一章铺路。
六、实现代码
src/session/session.ts:对话的一等公民
import { randomUUID } from "node:crypto";
import type { AgentRuntime } from "../agent/runtime";
import type { AgentRun } from "../agent/run";
import { AgentContext } from "../context/context";
import type { ContextSnapshot } from "../context/context";
import type { Message } from "../model/messages";
import { userMessage } from "../model/messages";
export interface SessionSnapshot {
id: string;
context: ContextSnapshot;
}
export class Session {
readonly id: string;
readonly context: AgentContext;
constructor(id: string = randomUUID(), messages: Message[] = []) {
this.id = id;
this.context = new AgentContext(messages);
}
async turn(runtime: AgentRuntime, prompt: string): Promise<AgentRun> {
this.context.add(userMessage(prompt));
return runtime.runContext(this.context);
}
snapshot(): SessionSnapshot {
return { id: this.id, context: this.context.snapshot() };
}
restore(snapshot: SessionSnapshot): void {
this.context.restore(snapshot.context);
}
}重点:turn() 三行——加 user 消息、在同一个 context 上跑一轮、返回 run。上下文在这里是被「持有」的,不是被「传参拼凑」的。
src/agent/runtime.ts:runContext 抽出上下文参数
把原来 run(request) 的主循环抽出来,改成接收外部 context(下面这张对比图看起来会更清晰,主要目的就是拿出runContext,供上面的Session内使用):

async run(request: ModelRequest): Promise<AgentRun> {
return this.runContext(new AgentContext(request.messages)); // 向后兼容:无 Session 时照旧
}
async runContext(context: AgentContext): Promise<AgentRun> {
// …原主循环,只是不再 `new AgentContext(request.messages)`,
// 直接使用传入的 context:context.add(...)、context.messages 都作用在这同一个对象上
}重点:run() 变成 runContext() 的薄包装——所有老的调用方(runAgentDemo、旧的 chat 用法)一行不改,而 Session 拥有了「在专属上下文上持续运行」的能力。
src/cli/chat.ts:从局部数组到 Session
接下来就是重点的改造就是应用层,将之前的history[]使用我们这一章实现的Session进行替换掉

下面是关键的代码变更点:
const session = new Session(undefined, [systemMessage(systemPrompt)]); // 播种 system prompt
console.log(`Session : ${session.id}`);
for (;;) {
const prompt = await ask();
if (prompt === null || prompt.trim() === "" || prompt === "exit" || prompt === "quit") break;
state.stepCount = 0;
state.retryCount = 0;
const run = await session.turn(runtime, prompt); // 一轮 = 一个节拍
printSummary(run, state);
}重点:history 局部数组消失了,换成 session.context——上下文住在 Session 里,跨轮自然生长。另外这版顺手修了旧 chat() 的一个坑:旧的 ask() 用 rl.question + once("close"),管道输入(如 echo "问题" | hello --chat)会把后面几行输入丢掉;新版改成 rl.on("line") 维护一个输入缓冲队列,交互和管道都能多轮。
src/cli/index.ts:把方法论交给会话
if (args.chat) {
await chat(model, registry, SYSTEM_PROMPT, options); // 把 ch24 的 SYSTEM_PROMPT 传进去播种
}重点:chat() 新增 systemPrompt 参数——ch24 的方法论第一次真正进入多轮对话,--chat 里的 Coding Agent 从此也讲「先观察、再修改、再验证」。
七、运行 Demo
跑法一:多轮会话(本章的演示,复现第三节转录)——进入 hello --chat,在同一个 Session 里先修 bug、再加功能:
$ hello --dir examples/stage-3/25-cli --chat
Hello Harness v1.0 · 流式多轮对话(输入 exit 退出,Ctrl+C 取消本轮)
Session : <随机 id>
你 > 帮我修复这个项目里的 bug:src/calc.mjs 的 factorial 函数结果不对,请先观察、再修改、最后运行 npm test 验证
…(第一轮:read → edit → npm test,pass 3 / fail 0)
你 > 很好,现在再帮我加一个 divide 函数,并补充一条测试
…(第二轮:还记得第一轮改了什么,read → edit/write → npm test,pass 5 / fail 0)
你 > exit完整的执行过程演示动画如下(推荐有兴趣有条件的小伙伴,实际跑一跑体验一下):

观察两轮的关键差异:
| 观察点 | 第一轮 | 第二轮 |
|---|---|---|
| 上下文 | 从零开始读项目 | 直接接上第一轮——记得 n - 1 是改过的 |
| Session id | 生成并打印 | 同一场对话,id 不变 |
| system prompt | 方法论生效 | 方法论持续生效 |
| 失败恢复 | — | edit 因 CRLF 匹配失败 → 重读 → 改 write 重试 → 成功 |
跑法二:脚本式多轮(管道输入)——不需要手敲,三行输入喂进去连续跑两轮:
$ printf '帮我修复这个项目里的 bug:src/calc.mjs 的 factorial 函数结果不对,请先观察、再修改、最后运行 npm test 验证\n很好,现在再帮我加一个 divide 函数,并补充一条测试\nexit\n' | hello --dir examples/stage-3/25-cli --chat这一章也不做无模型 demo:Session 的价值只有真实模型才显形——两轮对话共享上下文、记住上一轮改动、失败还能续着修,全在一次真实转录里。
八、新架构解决了什么?
- 多轮从「行为」变成「结构」:
Session { id, context }让对话成为一个对象——身份有 id、财产有 context、节拍有 turn(),不再靠局部数组硬拼; - 上下文真的被继承了:
runContext让每一轮都长在同一个 AgentContext 上——第二轮能记住第一轮修了什么、测了什么、得出什么结论; - 方法论进了多轮:
chat()播种 SYSTEM_PROMPT——--chat里的 Coding Agent 从此也讲「先观察、再修改、再验证」; - 对话可以被指认:
Session : <id>打印在启动行——「这场对话」有了可指向的句柄; - 管道多轮被修好:输入缓冲队列替代
once("close")——脚本也能连续喂多轮输入; - 持久化的地基打好了:
snapshot()/restore()让 Session 可导出可恢复——为 ch27 落盘铺好路。
九、它又引入了什么问题?
- Session 只活在内存里:关掉进程,
id和context一起消失——「这场对话」还在,但没人记得它; - 没有 Session 列表:用户无法知道「我有哪些历史会话」——没有索引,就没有「选一场续跑」的入口;
- 恢复是黑盒:
restore(snapshot)只能在同一个进程里用对象恢复——跨进程怎么恢复?需要序列化到磁盘再读回来; - 上下文只增不减:多轮对话消息越攒越长——迟早撑爆上下文窗口,需要裁剪/压缩(这是 Stage 4 后段的事);
- Session 和 CLI 还耦合着:Session 概念出现在
chat.ts里,还没有独立成可以被任意入口(CLI、测试、服务)复用的模块边界。
十、下一章
Session 有身份、有内容,但它还没留下足迹——进程一退出,一切都归零。下一章我们做 Session 持久化:把 { id, context } 写进 .sessions/session-id.json,让「这场对话」真正活过进程的一生。
Session 给了对话身份,下一章给它坟墓之外的存续——一场对话,值得被记住。
写代码、写文档的兄弟们,我们下一章见。欢迎点赞、关注公众号「一灰灰Blog」 我们下章见
微信公众号: 一灰灰Blog