【从0开发类OpenClaw】Day 3:AI 助手张嘴说话了,跟 langchain 干了一架

从零写一个类 OpenClaw 的 AI 助手框架,全程记录踩坑。今天是第三天:agent-core 核心库 + 流式对话接口 + 一个差点把人整自闭的"双包陷阱"。


前两天的进度回顾一下:Day 1 立了 monorepo 架子、白嫖了 antd-pro 写了三个配置页;Day 2 后端出生,NestJS 管着 CyberClaw.json 这一个"数据库",智能体/模型/工具三套 CRUD + 联动校验全齐。

但说白了,那会儿的 CyberClaw 是个"植物人"——配置能存能改,可它不会说话。今天的目标就一个:让 AI 助手真的张嘴。于是有了今天的标题:它说话了,代价是跟 langchain 干了一架。

开工前的小菜:给配置页修修补补

上午先干了几件小事,都是 Day 2 遗留的体验问题:

  • 新增按钮展开表单:之前三个配置页一进来就把新增表单平铺在那,占半屏还丑。改成点「新增」才展开,保存收起清空,逻辑清爽多了
  • 编辑弹窗:Agents / Models 卡片加了编辑按钮,点开是 Modal 表单回填——注意 antd 的 Modal 内容是懒渲染的,回填要用 useEffecteditingAgent 变化后再 setFieldsValue,直接在 destroyOnHidden 下裸调会失效,表单还没挂载呢
  • 根路径重定向/ 之前落到 antd-pro 模板自带的白板首页,一个 Hello World 级别的占位,终于重定向到 agents-config 了
  • React 双副本:测编辑弹窗时发现测试环境诡异报错,一查:react 被装了两份(webui 下一份、根 workspaces 提升一份)。显式声明 react/react-dom ^19.2.8 + 删掉 webui 下的副本 + vitest 配置 resolve.dedupe,一锅端

顺便说句测试的事:antd 的 Select 下拉对合成事件(fireEvent/click)装死不响应,后来是真浏览器 CDP 发"可信鼠标事件"才点开的——这个后面发布文章时还会用到,先埋个伏笔。

重头戏:packages/agent-core 出生

昨天文章里说"明天动工 packages/core 的 LLM 多 provider 客户端",今天动工了,但第一件事就是打自己的脸

说好的自研 LLM 客户端(DeepSeek/OpenAI/Ollama 都是 OpenAI 兼容协议,一家通吃)——调研了一圈发现,langchain 全给我干完了:多 provider、流式输出、工具调用、Agent 循环,全都有,还比我写得稳。自己造轮子?省省吧,能用现成的就别手搓,这道理 Day 1 就悟过一回,今天又悟一回。

于是 packages/agent-core@cyberclaw/agent-core v0.1.0)的定位变了:不重造 LLM 轮子,而是做配置到可运行智能体的桥梁——读 CyberClaw.json,把前端配置的 systemPrompt / modelId / tools 翻译成 langchain 能跑的 agent。包结构:

packages/agent-core/src/
  types.ts      配置类型(复用 ClawAgent/ClawModel/ClawTool)
  llm.ts        LLMClient 接口
  tool.ts       工具接口 + 注册表
  agent.ts      最小可运行循环(后来沦为教学演示用)
  langchain.ts  核心:createLangchainAgent,配置 → langchain agent
  config.ts     从仓库根向上找 CyberClaw.json

其实中间还有个插曲:我先自己写了个最小 Agent 循环(agent.ts,systemPrompt + model + tools 死循环调工具),写得还挺得意。结果接上 langchain 一看——人家 createAgent 一个函数全包了,还带 ReAct 推理、流式、历史管理。我那个最小循环直接被降级成"注释里的教学演示"。认了,真香

坑①:baseURL 藏哪儿了

第一个坑在 ChatOpenAI。按直觉写:

new ChatOpenAI({ model, apiKey, configuration: { baseURL } })

死活不生效,请求还是打到 OpenAI 官方地址。翻源码才发现:baseURL 存的是 clientConfig.baseURL,不是 configuration.baseURL。文档没明说,源码见真章:

new ChatOpenAI({ model, apiKey, clientConfig: { baseURL } })

这种"藏得深"的 API 习惯,今天后面还有一个更大的。

坑②:createReactAgent 说没就没

工具调用这块,一开始用的 createReactAgent,昨天写好的代码,今天一跑——deprecated,推荐迁移到 createAgent。版本更新的痛,你懂的:API 变了、参数变了、连导入路径都变。老老实实迁:

const agent = await createAgent({
  model,
  tools,
  systemPrompt,
  // 配置里 tools 的 parameters 就是标准 JSON Schema,直接传
});

顺带一提,DynamicStructuredTool 的 schema 直接吃 JSON Schema 对象,前端配置里写好的 parameters 原样塞进去就行,省了层转换。

坑③:双包陷阱(今天最狠的)

今天的重头戏来了,这个坑我排查了大半个下午。

现象:agent 明明正常跑完了(日志显示它该干嘛干嘛),但 streamMode: 'messages' 流式一个事件都产不出来。就像水龙头拧开了,水管却是堵的,水全憋在里面。

排查过程:先怀疑 stream 模式用错,换 values 模式——有输出了!再换回 messages——又没输出。那就缩小范围:chunk instanceof AIMessageChunk 判断……好,破案了,问题就出在这。

根因apps/api 是 CommonJS,@cyberclaw/agent-core 是 ESM,Node 24 用 require(esm) 把 ESM 包硬拽进来。结果 @langchain/core/messages 在 CJS 依赖图和 ESM 依赖图里被加载了两份实例。我 CJS 侧拿到的 AIMessageChunk 类,跟流里吐出来的 ESM 侧实例,不是同一个类instanceof 永远是 false,判断永远不命中,事件永远不产出。

这就是 JS 生态的经典老毛病——双包陷阱(dual package hazard)。ESM/CJS 混装的大型 monorepo,早晚遇上。

解法:不认身份,认形状——duck-typing:

// AIMessageChunk 形状:带 tool_call_chunks 数组
// ToolMessage 形状:带 tool_call_id 字符串
if (Array.isArray(chunk.tool_call_chunks)) { /* 模型 token / 工具调用声明 */ }
else if (typeof chunk.tool_call_id === 'string') { /* 工具结果 */ }

instanceof 是有钱人的玩法,我们这种混装环境就老实按形状认人。附带还踩了两个 ESM 子包的小坑:tsconfig 要显式 types: ["node"]package.jsonexports 必须带 require 条件(指向同一个 ESM dist),否则 CJS 消费者和 Jest 解析不了。export 不带 require 条件的 ESM 包,不是好包。

后端:POST /api/claw/chat,SSE 流式

核心库搞定,接后端。新开 apps/api/src/chat/ 模块,一个接口:POST /api/claw/chat,SSE 流式输出。协议设计上偷了个懒也取了个巧:

data: {"event":"agent_start","agentId":"...","agentName":"..."}
data: {"choices":[{"delta":{"role":"assistant","content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}          ← 打字机效果
data: {"event":"tool_start","tool":"web-search","args":"..."}
data: {"event":"tool_end","tool":"web-search","ok":true,...}
data: [DONE]

choices.delta 走 OpenAI 兼容协议——前端生态直接白嫖(antd 的 XStream、各家 SDK 全认这个格式);agent_start / tool_start / tool_end 是 CyberClaw 扩展事件,用来透出智能体信息和工具调用过程。前后端一份协议,谁都不含糊。

几个设计上的讲究:

  1. 先构建 agent,再开流。构建失败(404 智能体不存在 / 422 停用 / 模型未启用)走普通 JSON 错误响应,SSE 头都不设。不然错误响应被前端当事件流 parse,全炸。
  2. 客户端断开要中止执行res.on('close')AbortController.abort(),用户刷新页面或者取消请求,agent 别还傻乎乎跑完——token 可是钱。
  3. 工具执行器留注入点CHAT_TOOL_EXECUTORS token 注入,今天没实现真实工具,返回「[工具未实现]」占位。有意思的是,agent 收到这个占位会自己圆场——“这个功能我暂时还做不到,但我可以……”。模型真的很会给自己找台阶。
  4. 后端无状态。不保存会话,每次请求前端全量带历史。多轮对话靠前端 useXChat 维护 + 全量重发。
  5. tool_end 的结果截断到 1000 字符,防止超大工具结果把事件流撑爆。

前端:chatbot 页面换血

前端 chatbot 页面之前用的是 antd 演示用的假 Provider(假数据假流式),今天全部换掉:

  • 自定义 AbstractChatProvider 真请求 /api/claw/chat,SSE 逐 token 累积
  • 顶部智能体选择栏:读配置里启用的 agent,显示描述 + 绑定模型
  • 工具调用过程用 Tag 展示:执行中(蓝)/ 成功(绿)/ 失败(红)
  • 配置里没有智能体时,引导去配置页

页面不复杂,但"从假数据到真对话"这一步,是今天情绪价值最高的一刻——打字机一样蹦字的时候,是真的爽。

验证:65 + 13 + 23 全绿

  • agent-core 23 个测试(FakeListChatModel@langchain/core/utils/testing 导入模拟模型,bindTools 还支持工具规格)
  • chat 模块 9 个单测(agent 构建校验、token 流、工具事件、历史转换)
  • 总计 API 65 + webui 13 + agent-core 23,前后端 tsc / biome / prettier 全干净
  • 端到端:本地 mock 一个 OpenAI 兼容服务 + 临时配置,curl 完整链路验证 agent_start → tool_start → tool_end → token 流 → [DONE],多轮历史、404/422/400、模型 401 优雅降级全过
  • 前端 :8000 代理链路流式正常,登录 mock 正常

诚实交代两件事:第一,CyberClaw.json 里的模型还是占位 apiKey,真实对话会收到"401 API key",已验证会优雅显示,填真 key 就能聊;第二,工具执行器还是占位,agent 会收到「[工具未实现]」并正常作答——这是明天的主要候选活。

今天的成果

* a089454 refactor(agent-core): migrate from deprecated createReactAgent to createAgent
* c1f16bb feat(agent-core): load CyberClaw config and create langchain ReAct agent from it
* 1aa7b01 feat(agent-core): add Agent runtime with tool-call loop (systemPrompt + model + tools)
* 7eb5c42 feat(webui): hide create form behind 新增 button, expand on click with save/cancel
* fe27fe2 fix(webui): redirect root to agents-config instead of template dashboard
* e3159aa feat(webui): add edit modals for agents/models + tests, fix vitest react dedupe

(chat 模块和前端换血的代码还在工作区热乎着,明天一起提交——先测试再提交,规矩不能破。)

  • agent-core 核心库出生:配置 → 可运行的 langchain 智能体
  • AI 助手开口说话:SSE 流式对话接口 + 打字机效果前端
  • 双包陷阱结案:instanceof 失效 → duck-typing 认形状
  • 工具执行器留好注入点,就等真实工具上位

明天干点啥

今天说句实话,对话是通了,但还"嫩":

  1. 让用户看见 agent 在想什么——思考过程/思维链的流式透出。现在工具调用的过程能看到了,但模型"推理到哪一步了"是黑盒,把 reasoning 过程流出来,可玩性直接翻倍
  2. 对话记忆——现在是无状态全量重发,刷新页面记忆清零。接下来要么后端做会话存储,要么把配置里那个 memory 工具执行器真接上,让 agent 真正"记得"用户
  3. 工具执行器正式接入(web-search 打头阵),让"会用工具"从占位变成真的

三条路都想走,明天先啃哪个,看心情(大概率是记忆,毕竟"记得你"比"会搜索"更戳人)。

今天到这儿。AI 助手开口说话了,虽然说得还磕巴,但万事开头难,开头这不就来了。


仓库地址:https://github.com/Li-Esquire-codeverse/CyberClaw(欢迎 star 监督进度)

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐