Agent SDK 会话体系与跨轮记忆
Agent SDK 四层会话体系按需选型:一次性查询适合单次交互,进程内会话适用于同任务多轮,可恢复会话通过 Session API 跨进程重建历史,应用层存储则自主管理持久化、容量与成本。
示例版本说明:本文对照 CodeBuddy 官方示例仓库 提交
25953e3核对。下文代码为结构摘录,省略了部分导入、类型和实现;博客仓库不包含这些示例目录。unstable_v2_*属于不稳定接口,运行时请锁定依赖并对照当前文档。
先看要点
- Agent SDK 的会话体系分四层:一次性查询 → 进程内会话 → 可恢复会话 → 应用层存储,四者不是替代关系,而是由「记忆存多久、存哪里」决定取舍。
- 核心 API 一句话:
unstable_v2_createSession()建会话(发send收stream),流里那条system/init消息会吐出session_id,把它存起来,下次用unstable_v2_resumeSession(sessionId)可在既有会话记录仍可访问时恢复对话,不能承诺保留全部原始历史。 chat-demo的教训很有代表性:应用层把消息存了 Map、也渲染成 UI,但模型每轮用的是未携带历史或恢复参数的新query()调用——「界面有历史」不等于「模型记得」。要跨轮记忆,就得让历史真正进入模型上下文。- 工程上只需盯住三件事:拿稳 session_id、想清记忆边界、设好过期与清理。
一、先分清四个层次
“会话”这个词在不同的代码里往往指不同东西。先建立一张总表:
| 层次 | 谁来存 | 生命周期 | 典型 API / 代码 | 代表示例 |
|---|---|---|---|---|
| ① 一次性查询 | 无 | 单轮 | query() / unstable_v2_prompt() | quick-start |
| ② 进程内会话 | SDK(内存) | 一个进程实例内、多轮 | createSession() + send()/stream() | multi-turn-session 的 multi-turn |
| ③ 可恢复会话 | SDK / CLI 可访问的会话记录 | 在记录仍可访问时跨进程、跨时间 | createSession() 拿 session_id → resumeSession(id) | multi-turn-session 的 resume |
| ④ 应用层存储 | 你自己 | 想存多久存多久 | Map / SQLite / Redis | chat-demo、mail-assistant |
关键心智模型:
模型“记得”什么 = 每次请求带进上下文的对话历史
① 每次请求 = 白纸 ② 同一 session 连续 send = SDK 替你拼接历史 ③ resume 旧 session = SDK 找回历史 ④ 你自己拼历史再 query = 完全可控但全要自己管二、三分钟看完四个示例
multi-turn-session/examples.ts 一次给了四个可运行的例子,正好是四个层次的递进。运行方式:
cd multi-turn-sessionnpx tsx examples.ts basic # ① 基础会话(发送/接收)npx tsx examples.ts multi-turn # ② 多轮对话(核心价值)npx tsx examples.ts one-shot # ③ 单次查询便捷函数npx tsx examples.ts resume # ④ 会话恢复(跨会话保持上下文)② 多轮对话:Session 的核心价值
// multi-turn-session/examples.ts(摘录)await using session = unstable_v2_createSession({ }); // 不指定模型,使用默认配置
// 第一轮await session.send('今年是哪一年?只回答年份数字。');for await (const msg of session.stream()) { if (msg.type === 'assistant') { const text = msg.message.content.find((c): c is { type: 'text'; text: string } => c.type === 'text'); console.log(`第一轮:${text?.text}`); }}
// 第二轮 - 智能体记住上下文await session.send('再往后推10年是哪一年?只回答数字。');for await (const msg of session.stream()) { /* …同样地消费 stream… */}两个语法点:
await using:TypeScript 的显式资源管理。会话是“有生命周期”的资源,作用域结束自动释放,不用手动 close,也不用担心忘记清理。send()/stream()分离:send()只是把问题发出去,答案通过异步流stream()逐条取——后面接流式 UI、接广播,都是同一个模式。
④ 会话恢复:把记忆“冻起来,再解冻”
// multi-turn-session/examples.ts(摘录)let sessionId: string | undefined;
// 第一个会话 - 建立记忆{ await using session = unstable_v2_createSession({ }); await session.send('我的幸运数字是7,请帮我记住这个信息!');
for await (const msg of session.stream()) { if (msg.type === 'system' && msg.subtype === 'init') { sessionId = msg.session_id; // ← 关键!从这里拿会话 ID } /* …消费 assistant 消息… */ }}
console.log('--- 会话已关闭,时间流逝... ---\n');
// 第二个会话 - 恢复并验证记忆{ await using session = unstable_v2_resumeSession(sessionId!, { }); // 用 ID 复活 await session.send('我之前告诉你的幸运数字是多少?'); // 它还记得 7 /* …消费 assistant 消息… */}注意 本例的
session_id从第一轮stream()中的system/init消息提取。应以锁定版本的 API 为准,不要假定仅保存一个 ID 就同时备份了会话文件和外部工作区。
③ 单次查询:不想开会话时的捷径
// 不需要维护状态、只想要“问一次拿答案”const result = await unstable_v2_prompt('长江的源头在哪个省份?只回答省份名称。', { });
if (result.subtype === 'success') { console.log(`回答:${result.result}`); console.log(`费用:$${result.total_cost_usd.toFixed(4)}`); // 连账单都带回来}三、一个容易被忽略的坑:界面有历史 ≠ 模型记得
chat-demo 会在界面里保留聊天记录,刷新后也能回放,但这不代表下一次模型调用已经带上了历史。这里需要分别检查应用存储和模型上下文。
不。看它的数据流:
// chat-demo/server/agent.ts(摘录)async sendMessage(content: string) { this.currentStream = query({ prompt: content, // ← 每次只把“当前这句”发给模型 options: { systemPrompt: SYSTEM_PROMPT, /* … */ }, })[Symbol.asyncIterator]();}chat-demo 的历史存在两个地方:
| 数据 | 存在哪 | 谁看得见 |
|---|---|---|
| 用户/助手消息(带时间戳) | store.ts 的内存 Map | 前端 UI、REST 接口——模型看不见 |
固定系统提示词 SYSTEM_PROMPT | agent.ts 常量 | 模型能看见,但它是常量,不是对话历史 |
所以真相是:聊到第二轮时,模型依然是一张白纸,它只看到 SYSTEM_PROMPT + 用户当前这句话。历史消息存在 Map 里纯粹是为了 UI 展示与回放。
把这条结论说透,就是本系列第 3 篇那句「SDK 只干一件事」的补充版:
当前示例没有恢复会话,也没有传入历史,所以每轮是新上下文;这不是
query()API 永远无状态。官方 SDK 还提供options.resume等会话恢复配置,也可使用 Session API 或显式组织历史。
对照:三种把“记忆”喂给模型的做法
| 做法 | 代码量 | 可控性 | 适用 |
|---|---|---|---|
每次手动拼历史:prompt = 拼接(历史 + 新问题) | 中 | 最高(截断策略、消息改写全自己定) | 要精细控制 token 与上下文 |
Session API(send/stream 循环) | 低 | 高(记忆边界由 SDK 管理) | 进程内长对话 |
resumeSession(sessionId) | 最低 | 中(依赖已有会话记录可用性) | 跨进程/跨时段的“续聊” |
四、从存储位置理解 Session 的设计
把几个示例拼起来看,Session 机制背后其实是一套通用工程推演:
用户要“连续对话” │ 如果每轮都无状态 → 模型每轮失忆 ▼需要“进程内会话” → 建 session、连续 send/stream (②) │ 进程一重启,记忆就没了 ▼需要“会话持久化/恢复” → 提取 session_id、resumeSession (③) │ 但 session_id 放哪?进程内存显然不行 ▼需要“应用层状态管理” → Map / SQLite / Redis 存会话与历史 (④) │ 还要解决:多客户端共享、断线重连、过期清理 ▼落地为一个可运行的会话服务(如 chat-demo 的 sessions Map)这也是为什么 chat-demo 的 ChatSession 用 Map<chatId, ChatSession> 管理:它模拟的正是「④ 应用层」——用 chatId 当业务键,把 SDK 会话(或其替代品)挂在其下。生产实现还要补上持久化、用户与会话隔离、并发控制、恢复失败处理和过期清理,不能只把 Map 替换成数据库。
五、实现 Session 时的检查项
-
拿稳
session_id从第一轮system/init消息中提取后,立刻持久化(DB 列 / Redis),别等“需要时再取”。 -
想清“记忆边界” 一次会话共享一份记忆,但主题往往漂移。长对话要自己定截断/摘要策略——SDK 替你拼历史,SDK / CLI 可能有自己的上下文压缩机制,应用仍需决定哪些业务信息必须保留。
-
生命周期与清理 无界 session = 无界账单与存储。参考
chat-demo的数字:单会话最多 100 条、全局最多 50 个会话、LRU 淘汰;再结合result消息里的total_cost_usd做成本监控。 -
幂等与重试 客户端网络抖动时,重发
send可能造成重复;用消息 ID 幂等,并区分续聊与重试;resumeSession本身不提供业务操作幂等保证。 -
无状态接口做兜底 不需要记忆的查询(如“翻译这句话”)永远走
unstable_v2_prompt()/query(),别让它们污染长会话上下文——这也是成本优化。
六、小结:一张决策表收尾
| 你的场景 | 推荐做法 |
|---|---|
| 工具类单次调用(翻译、摘要、改写) | query() / unstable_v2_prompt(),别开会话 |
| 进程内的连续对话(CLI/REPL/单机流程) | createSession() + send()/stream() |
| 对话要“隔夜续聊”、多端同步 | 持久化 session_id → resumeSession() |
| 要自己掌控历史、做 RAG/摘要注入 | 手动拼历史 + query() |
| 多用户 Web 服务 | 应用层(④)按 chatId 建会话,配过期清理;生产用 DB 存 session_id 与历史 |
记住最反直觉的那一条:“界面记得”和“模型记得”是两件事。前者靠你的存储,后者靠你把历史真正送进模型的上下文——要么靠 Session API,要么靠自己的 prompt 拼接。
核对资料
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
正在加载 GitHub 评论…