从零打造CyberClaw:AI 记住了你是谁
【从0开发类OpenClaw】Day 4:AI 记住了你是谁,还有一场"会话消失"的破案
从零写一个类 OpenClaw 的 AI 助手框架,全程记录踩坑。今天是第四天:思考过程流式透出 + 对话记忆(langgraph checkpointer + SQLite 会话持久化)+ 一场差点让人以为数据丢了的大排查。
前情回顾:Day 1 立了 monorepo 架子,白嫖 antd-pro 写了三个配置页;Day 2 后端出生,NestJS 管着 CyberClaw.json,三套 CRUD + 联动校验全齐;Day 3 智能体开口说话了,SSE 流式 + 打字机效果 + langchain 双包陷阱结案。
但 Day 3 结尾我自曝了三个"嫩"点:模型思考过程是黑盒、对话无记忆(无状态全量重发,刷新就清零)、工具执行器还是占位。今天就是来还账的——前两笔账还掉了,还附赠一场惊心动魄的"会话消失"破案。
第一笔账:让 AI 说出它在想什么
Day 3 的时候,工具调用过程能看到了(tool_start / tool_end),但模型"推理到哪一步"是黑盒——尤其是思考型模型,憋半天憋出一个答案,用户完全不知道它经历了什么。
原理上这事不难:DeepSeek 这类模型的输出里自带 reasoning_content(思考内容),和正文 content 是分开的两个字段。langchain-openai 1.5.8 开始把 reasoning_content 透传到 additional_kwargs.reasoning_content——后端在 chat.service.ts 里从那里把思考增量抠出来:
// chat.service.ts:从 langchain 流里提取思考增量
if (chunk.additional_kwargs?.reasoning_content) {
const reasoning = String(chunk.additional_kwargs.reasoning_content);
// 以 reasoning_delta 扩展事件下发,赶在正文 delta 之前
}
SSE 协议因此多了一个扩展事件,还是 Day 3 那套"OpenAI 兼容 + CyberClaw 扩展"的路子:
data: {"event":"reasoning_delta","reasoning":"用户问的是算术题,"}
data: {"event":"reasoning_delta","reasoning":"我需要计算 1+2"}
data: {"choices":[{"delta":{"content":"结果是 3"}}]} ← 思考先到,正文后到
data: [DONE]
前端必须把思考内容和正文分开累积:reasoning_delta 攒到 thinkContent,choices.delta.content 攒到 content,互不覆盖。气泡头部用 antd-x 的 Think 组件渲染,默认展开,模型一边想一边实时蹦字——可玩性直接翻倍。
这里踩了个真坑,值得单开一段。
坑①:parser 返回新对象,把工具调用弄断了
前端 useXChat 的 parser 负责把流式消息转换成语义化的结构。我一开始图省事这么写:
const result = { role: 'assistant', content: finalContent };
if (finalThink) result.thinkContent = finalThink;
if (tools?.length) result.tools = tools;
return result;
看着没毛病?有。useXChat 内部拿 parser 的返回值跟原消息合并的时候,自定义字段(thinkContent / tools)会被悄悄丢掉——表现就是:模型开始思考、开始调工具,但前端工具轨迹直接断流,调试半天找不到原因,最后发现是 parser 每次返回一个全新对象,把 provider 透传的字段洗没了。
解法:parser 必须基于原消息透传自定义字段,只覆盖内容类字段:
const result: ParsedMessage = { role: 'assistant', content: finalContent };
if (finalThink) result.thinkContent = finalThink;
if (tools?.length) result.tools = tools; // 原样透传,别重新构造
血的教训:自定义协议字段的透传,永远是"在原有对象上改"而不是"新造一个"。
第二笔账:对话记忆,刷新不再失忆
Day 3 是无状态全量重发——多轮对话靠前端把历史全量塞给后端,刷新页面记忆清零。今天的目标:让 agent 真正"记得"用户。
方案选择上没纠结:langgraph 自带 checkpointer(检查点持久化),@langchain/langgraph-checkpoint-sqlite 的 SqliteSaver 把线程状态落到 SQLite,一行配置的事:
const checkpointer = SqliteSaver.fromConnString('data/cyberclaw.db');
const agent = await createAgent({ model, tools, systemPrompt, checkpointer });
关键设计:线程 id(thread_id)就是会话 id。前端每次请求带 conversationId,后端把它当 thread_id 传给 langgraph——同一个会话的连续对话共享记忆,新会话天然隔离。还留了个 CHAT_CHECKPOINTER 注入 token 做替换点,service 层零改动。
效果立竿见影。测试的时候我连问了三句:“你是谁?” → “我是小李” → “我是谁?” —— 模型回答"你是小李呀!我们刚才认识的,我已经记住你了"。那一刻是真的有点感动,AI 记住你了。
第三笔账:会话列表,SQLite 里的"户口本"
记忆有了,但还缺个"户口本"——会话列表本身。不然页面一刷新,用户连"有哪些会话"都不知道。于是 conversations.store.ts 出生:
apps/api/src/chat/
conversations.store.ts 会话元数据表(better-sqlite3,与 checkpointer 共用 db)
conversations.controller.ts GET/POST /api/claw/conversations、DELETE /:id
chat.service.ts upsert / list / remove + 历史回显
API 设计:
GET /api/claw/conversations?agentId=按智能体列出会话(更新时间倒序)POST /api/claw/conversationsupsert(新建/重命名/刷新时间戳都走它,ON CONFLICT(id) DO UPDATE一个 SQL 搞定)DELETE /api/claw/conversations/:id删会话的同时联动删线程记忆(SqliteSaver.deleteThread)GET /api/claw/chat/history?agentId=&conversationId=历史回显:读 langgraph 线程快照,过滤掉 system/tool 消息,保留 thinkContent——思考过程也要能回放
前端 useXChat 的 defaultMessages 接住历史回显:切换会话时按 conversationKey 从后端拉线程消息,聊天记录无缝还原。顺手还修了个坑:会话 id 一开始用自增计数(conv-1, conv-2…),页面一刷新从 1 重新数,跟后端已持久化的旧会话撞 id——新建对话串到旧会话、删除误删旧记录。改成 crypto.randomUUID(),全局唯一,一劳永逸。
第四笔账:会话重命名/删除 UI
户口本有了,得能管。昨天(严格说是今天上午)给会话列表补了两个操作:
重命名:会话上点「⋯」→ 重命名 → Modal 弹出输入框(预填当前标题,自动聚焦,Enter 提交)→ 走 upsert 保存。前端标签即时更新,草稿会话重命名后自动落库,刷新不丢。
删除:原来点一下就删,太危险——加了 Modal.confirm 二次确认,显示会话名、标注"对话记录将一并清除且无法恢复"、红色危险按钮;后端删失败会提示且保留会话。
坑②:antd Modal.confirm 在 jsdom 下的"僵尸"
给删除确认写测试的时候,遇到一个很邪门的测试基建问题:Modal.confirm 关闭动画在 jsdom 下永不完成,渲染它的独立 React root 会残留在 DOM 里,污染后面的测试用例——一个用例刚跑完,下一个用例一查 findByRole('dialog'),好家伙,俩。
一开始想用 antd 官方的 Modal.destroyAll() 清理,实测无效(异步 root 关不掉);手动 removeChild 又会跟 React 卸载打架报 Failed to execute 'removeChild'。
最终解法很干脆:测试里把 Modal.confirm mock 掉,组件逻辑(传入的 config 内容 + onOk 行为)照样确定性验证,真实弹窗渲染是 antd 自己的事,jsdom 里测它纯属自找麻烦:
Modal: Object.assign(actual.Modal, {
confirm: mockModalConfirm, // 捕获 config,测试里直接触发 onOk
}),
顺带一个细节坑:mock 时千万别 { ...actual.Modal, confirm: mock }——展开一个函数组件会把可调用性丢掉,<Modal> 直接报 “Element type is invalid”。要 Object.assign 保留原函数。
重头戏:一场"会话消失"的破案
UI 全做完,用户验证去了。回来一句:“重命名成功后,删了一个会话,再新建一个对话,原来重命名的会话不见了,只剩下新建的。”
第一反应:完了,是不是删除误删了?重命名是不是把数据写坏了?二话不说,直接查 SQLite:
conversations 表:
conv-790855a1 『自我介绍』 ← 重命名的会话,agent: 法律文书智能体
conv-b6c9fcc9 『你好,你可以做些什么呢』 ← 新建的,agent: 代码助手
checkpoints 线程:
conv-790855a1 9 条消息 ← 完好无损
等等——重命名的会话还在库里!它属于法律文书智能体,而新建的对话属于代码助手。破案了:
- 用户在法律文书智能体下重命名、删除了会话,都没问题
- 之后智能体被切到了代码助手(顶栏下拉框),新建的对话落在代码助手名下
- 会话列表是按智能体过滤的——站在代码助手页面上,当然看不到法律文书智能体的会话
数据一分没丢,纯粹是"按智能体隔离"的设计让用户产生了误会。这个设计本身没错(每个智能体的线程上下文本来就是独立的),但上下文关系不够显眼就是设计缺陷。
修复:侧栏顶部加了一个智能体上下文标题——显示当前智能体名称 + 会话数(“法律文书智能体 · 2 个会话”),切换智能体时一目了然,再也不会无声无息地"消失"。
同时用测试把疑案钉死:写了个「重命名 B + 删除 A + 新建对话 → B 仍在列表中」的完整链路用例,直接复现用户的操作序列;再加「切换智能体切回后旧会话仍在」——两条用例全绿,前端状态流转实锤无 bug。
破案心得:用户报"数据没了"的时候,先查数据还在不在(后端 / 数据库),再查是不是前端显示问题——90% 的"丢数据"是显示层误会。这次要不是先查了 SQLite,就得白改一堆前端代码。
验证:98 + 75 全绿,链路全通
- 前端 webui:98 个测试全绿(会话管理组件测试 7 个新增:重命名预填/保存、空标题拦截、删除二次确认、完整链路、智能体切换、删除失败保留),typecheck 干净
- 后端 API:75 个测试全绿(7 个套件)
- 端到端:SQLite 直查证实记忆线程 + 会话列表数据完好;
/api/claw/conversations各智能体过滤正确;/api/claw/chat/history回显含思考内容 - 开发服务器:webui
:8000(登录 admin/ant.design)+ api:3000双活,热更新正常
诚实交代:真实工具执行器还是占位(agent 会收到「[工具未实现]」并自己圆场)——这是 Day 3 就欠下的账,明天继续还。
今天的成果
* 7ec5bd3 feat(webui): conversation rename/delete UI, agent context header, component tests
* 8bbaa6e fix(webui): conversation id collision on new chat
* 180c9d3 feat(chat): persist conversation memory in SQLite + history/conversation APIs
* 1ce9738 feat(chat): conversation memory via langgraph checkpointer (thread_id)
* 4ab3735 feat(chat): stream model reasoning via reasoning_delta and fix tool trace display
(5 个 commit 已全部推送到 origin/dev。)
- AI 记住了你是谁:langgraph checkpointer + thread_id 会话记忆,刷新不失忆
- 思考过程不再黑盒:reasoning_delta 流式透出,模型一边想一边给你看
- 会话有户口本了:SQLite 会话列表 + 历史回显 + 重命名/删除 UI
- "会话消失"疑案结案:不是数据丢了,是智能体被切换了——侧栏上下文标题防再犯
明天干点啥
记忆和会话管理都齐了,还差最关键的一块:
- 真实工具执行器——web-search 打头阵,让"会用工具"从占位变成真的。工具能真跑,才是从"会聊天的机器人"到"能干活的助手"的分水岭
- 跨智能体会话管理——现在列表按智能体隔离(各有各的户口本),要不要做一个"全部会话"视图,带智能体标签、点哪个切哪个?用户今天已经在这上面栽过一次,值得认真想想
- 思考过程 UI 打磨——Think 组件现在默认全展开,长思考会把气泡撑得老高,折叠记忆 + 收起态样式可以再雕琢
今天到这儿。AI 记住了你是谁,会话也不丢了——从"会说话"到"记得你",距离"能干活"只差最后一步。
仓库地址:https://github.com/Li-Esquire-codeverse/CyberClaw(欢迎 star 监督进度)
更多推荐
所有评论(0)