第 6 步 · 对话历史

跨轮记忆 + 截断策略 —— 让 agent "记住"你前面说过的话

配套可运行项目:f:\myagent\task6 | 前置:已完成 Step 5(交互模式 REPL)

本步的心智转变:历史从"局部变量"变成"跨轮共享状态"

task 5 的 REPL 每轮都是独立会话:你问"我叫小明",下一轮问"我叫什么",它答不上来——因为每次 runAgent 都新建了一个 messages 数组。

为什么模型记不住? LLM 本身是无状态的:每次请求只"看"你这次发给它的 messages,不记得上次请求说了什么。所谓"记忆"只是把历史攒在一个数组里、每轮重新发一遍。历史 = 你每次请求喂给模型的全部上下文。想让 agent 记住,就得让"同一份数组"跨轮传递、每轮追加。
本步要做的三件事 ① 新建 src/history.tsSYSTEM_PROMPT 常量 + createHistory()(新会话)+ trimHistory()(截断策略);② 改造 src/agent.ts:签名变成 runAgent(task, history),每轮往同一份历史追加、结束后自动截断;③ 改造 src/main.ts:持有历史数组,新增 /clear 命令(重置上下文,不退出程序)。
本步最大的坑:上下文窗口是有限的 历史不能无限增长。上下文窗口(context window)是模型一次能"看"的上限(几万 token 而已),塞爆了要么报错、要么丢掉最近的对话。所以必须截断:永远保留 system,丢掉最旧的对话,保住最近的。另一个容易忽略的点:截断是原地修改共享数组splice 而不是 slice)——因为 main.ts 还拿着同一个引用,换数组会让两边失去同步。
6

对话历史(跨轮记忆 + 截断策略)

目标:连续对话时模型记得前面说过的话;/clear 一键重置;聊再多也不爆上下文。

6.1 新建 src/history.ts:system 常量 + 建历史 + 截断

新文件,回答三个问题:system 提示写在哪?历史放哪?超出上限怎么办?

src/history.ts(本步新增)
为什么必须截断? 上下文窗口是硬上限:模型一次只能"看"几万 token。假设每轮对话平均 1k token,聊 50 轮就是 50k——超限后请求直接报错,更糟的是有些服务会悄悄截掉最前面的内容,你的 system 提示可能先被丢掉,agent 行为立刻"失忆变傻"。截断策略保证:无论聊多久,发给模型的内容永远是"system + 最近一段对话",稳定在安全范围内。
splice vs slice:共享状态只能用原地方法 splice(1, n)原数组上删元素(返回被删的,但数组本身被改);slice(1) 返回一个新数组,原数组不变。历史是 main.ts 和 runAgent 共享的同一个引用——如果 trimHistory 用 slice 返回新数组,main.ts 手里的旧数组还是超长的,截断等于没截。被共享的状态,修改必须用原地方法,这是写协作代码的通用原则。
常见坑 ① 截断参数写错:splice(1, history.length - max)1 是"从下标 1 开始删"——从 0 删会把 system 删掉;② MAX_HISTORY 设成 1:那 system 之后全被删,等于没有对话;③ 忘了每轮结束后调用:只在启动时截一次,聊久了照样爆。

6.2 改造 src/agent.ts:签名变成 runAgent(task, history) + 自动截断

核心变化:① 历史由调用方传入(不再自己建);② 循环里直接复用这份历史(模型能看到之前所有轮次);③ 本轮结束自动 trimHistory

src/agent.ts(升级)
升级前:src/agent.ts(task 5,每轮独立会话)
为什么传数组而不是返回新数组? JavaScript 数组是引用类型:函数拿到的是"同一个数组"的句柄。runAgent 在数组上 push,main.ts 手里的同一份历史就变了——调用方无需接收返回值。如果改成"返回新数组",main.ts 还得记得接住并重新赋值,漏一处就断链。引用传递让"共享状态"的读写都发生在同一个数组上,这是跨函数共享数据的核心机制。
为什么截断放在 runAgent 末尾而不是 main.ts? 因为"一轮结束"这个概念只有 runAgent 内部知道(它可能循环 10 轮工具调用,这些中间消息不能截)。把 trimHistory 放在函数尾部,保证无论这一轮走了几条工具消息,结束后历史一定被收紧到安全范围。main.ts 只负责"持有"和"传入",不关心策略细节——职责分离。
常见坑 ① 忘了在 main.ts 里把同一份 history 传回来:每轮传新数组,等于没记忆;② 截断后 system 丢了:splice 起点必须是 1(0 号 system 永远保留);③ MAX_HISTORY 设置成"按条数":真实产品要按 token 数算,一条工具结果可能顶 10 句对话。

6.3 改造 src/main.ts:持有历史 + 新增 /clear

main.ts 只改三处:createHistory() 初始化历史(★1);runAgent(text, history) 传同一份历史(★2);斜杠命令分支里加 /clear(★3)。

src/main.ts(升级)
升级前:src/main.ts(task 5,无历史 /clear)
/clear/quit 的区别 /quit 退出程序(rl.close() → close 事件 → 再见);/clear 只重置上下文(history = createHistory() → 打印提示 → 继续等你)。一个是"关门走人",一个是"清空黑板继续上课"。三个命令各管一件事:/quit 退出、/help 看帮助、/clear 重置记忆。
/clear 为什么只是重新赋值一行代码? 因为"历史"就是 main.ts 持有的那个数组。history = createHistory() 让变量指向一份只有 system 的新数组,旧数组没有任何引用指向它,会被垃圾回收。这行代码同时完成了"丢弃旧记忆"和"初始化新会话"两件事——把状态集中在一个变量上,功能就自然集中在一行上。这也是为什么 createHistory 要"返回数组"而不是"原地清空":重新赋值语义更清晰。
常见坑 ① 把 /clear 写进 /help 的 if 里:变成"永远走不到"的分支,顺序要用 if / else if / else 并列;② 清了历史但忘了清 busy:下一轮输入被永久忽略;③ historyconst 声明:/clear 重新赋值直接报错(必须 let)。

6.4 运行验收:连续对话 + /clear + 截断

npm run chat 后重点验证三点:跨轮记忆、/clear 重置、截断不报错。为了快速看到截断,可把 MAX_HISTORY 临时改成 4,聊到第 3~4 轮后观察历史变短(日志中工具仍能正常调用即可)。

终端 · 运行命令
验收标准(来自课程大纲) ① 连续对话能记住前面轮次的信息(问"我叫什么"能答出"小明");② /clear 后模型"失忆",且程序不退出;③ 连续聊 20+ 轮不报错、不丢最近的对话(截断生效);④ npm run agent 的单次用法仍正常(直接运行分支创建了自己的历史)。四条都满足,task 6 通关。
常见坑 ① 问"我叫什么"答不上来:检查 main.ts 是否每轮都传同一个 history 变量(传了新的就白搭);② 聊久了请求报错:截断没生效,检查 trimHistory 是否在 runAgent 末尾被调用;③ 工具结果特别长时历史增长飞快:理解"按条数截断是教学简化",真实产品按 token 数。
?

字段来源速查表(task 6 新增:对话历史相关)

前几步的速查表在 agent-tutorial0123.html / task4.html / task5.html。这里只列 task 6 新增/涉及的名字。判断口诀不变:上网络的键名 = 协议定;只在你代码里出现的 = 你定,但要对齐。

字段 / 值能不能改谁规定的说明
SYSTEM_PROMPT 内容自己起你自己角色设定;作为历史第 0 条,截断时永远保留
createHistory()自己起你自己新建"只有 system 一条"的历史;启动时初始化 / /clear 后重置
trimHistory(history, max)自己起你自己截断策略;原地修改共享数组,丢最旧保最近
MAX_HISTORY = 20自己起你自己教学用小值,方便观察截断;真实产品按 token 数而非条数
splice(1, history.length - max)不能改JS 数组方法原地删除;起点 1 保证 system(0 号)不被删
runAgent(task, history) 签名自己起你自己历史由调用方持有、跨轮传入;数组引用传递,原地追加
/clear 命令自己起你自己重置上下文不退出程序;history = createHistory() 一行完成
消息 role:system/user/assistant/tool不能改OpenAI 兼容协议与 task 1~5 相同,见 agent-tutorial0123.html
工具调用消息的 tool_calls / tool_call_id不能改OpenAI 兼容协议assistant 原样带回、tool 回填时必须携带 tool_call_id
一句话记住 task 6 "历史是跨轮共享的累加器"。把 history 提到函数外、同一份数组跨轮传、每轮 append;结束就 trimHistory(保 system、丢最旧);/clear = 重新赋值 = 旧数组被垃圾回收。截断的关键:共享状态要用原地方法 splice,不是 slice
← Home