那个 Promise resolve 的时候,你用户已经对着空白页发了 40 秒呆。
这不是性能问题。这是产品层面的硬伤——LLM Agent 做推理天生就慢,一个中等复杂度的任务跑个 3 到 5 轮 很正常,每轮都要等模型吐完 token、解析结构化输出、跑一下外部调用、再把结果塞回 数组喂回去,整条链路跑下来十几秒起步,你要是把这些全藏在一个 后面,用户的耐心大概撑不过第二轮。所以真正要解决的问题不是”怎么让 Agent 跑起来”,是怎么把它边跑边想的过程实时地、结构化地渲染出来(当然这是理想情况)。
Tool 选择、参数组装、中间结果、重试决策。全得摊开给用户看。说白了嘛,就是给 LLM 的”内心戏”搭一个可视化的舞台,让用户知道它不是卡死了而是真的在干活。跑通一个 demo 不难,难的是这套东西在生产环境里不崩——两个字概括就是”耐操”。
这个流程画出来像个 DAG。但运行时它是动态生长的——你在第一步根本不知道后面会长出几个分支,也不知道哪个 会超时、哪个会返回意料之外的格式让 LLM 的 直接炸掉。这篇文章围绕这个矛盾展开:怎么设计一套前端架构让 Tool 可插拔注册、思维链状态可追踪、DAG 可实时渲染,同时不把代码写成一坨谁都不想维护的东西。
先上问题。LangChain.js 里注册 Tool 的标准姿势大概长这样:
一个 Tool 写成这样没问题。三个也凑合。十五个呢?
真实项目里 Agent 要调的 Tool 很容易膨胀到两位数——搜索、计算、、文件读写、外部 调用、沙箱代码执行——每一个都有自己的 定义、错误处理逻辑、重试策略、权限校验规则,你要是把它们全塞在一个文件里就会得到一个 800 行的 ,三个月后没人敢碰这玩意。
需要 registry 模式。
然后每个 Tool 自己单独一个文件,文件末尾做自注册, 的副作用就是把自己挂到 上:
这个模式有个隐含的坑。
静态注册搞定了。
但跑起来还有一层:Tool 执行过程中的生命周期钩子。你需要知道一个 Tool 什么时候开始执行、什么时候结束、返回了什么、报错了没有——这些信息不只是后面思维链可视化的数据源,它就是思维链本身的骨架,没有这些事件流你后面画个锤子的 DAG。
嗯,继续。
LangChain.js 原生提供了 机制来做这事。但它的回调设计——怎么说呢——有点”Java 味儿”,、、 一堆方法签名糊你脸上,参数类型还经常对不上文档(虽然这个设计我觉得有点奇怪,明明 TypeScript 项目为什么类型定义这么随意)。我的做法是在 registry 层包一层代理把 Tool 的 拦截掉:
这段代码有个细节值得停一下。 里塞 做超时兜底这个套路很常见,但用在 LangChain Tool 里有一个陷阱——timeout reject 之后原始的 或者数据库查询其实还在跑着呢。你的 Agent 已经收到报错往下走了,后台还挂着一个请求在那耗资源。前端并发高这个说法本身就有点奇怪对吧?一个用户一次也就跑一个 Agent。但你仔细想——如果 Agent 支持并行 Tool 调用,同时起 3、4 个 ,再叠上用户可能开了好几个对话 tab 每个 tab 都在跑,这个泄漏就不是理论问题了, 是正解但 不方便把 传进 里,得自己在闭包里存一个,写出来不好看,先欠着。
嗯,继续。
真正让 registry 模式值回票价的是动态 Tool 集,不同用户角色、不同对话场景,Agent 能调的 Tool 不一样。管理员能用 ,普通用户碰都别碰(虽然官方文档不是这么说的)。哦不,准确说是用 ,普通用户碰都别碰(虽然官方文档不是这么说的)。处理代码问题时加载 ,闲聊天的时候不需要。
这段 看着朴素,本质上是把 Tool 的注册和使用解耦了。
不过话说回来。这套 registry 最大的受益者不是运行时(虽然官方文档不是这么说的)。是后面的 DAG 渲染,因为 、 这些事件流出来了,思维链的数据源就有了。
跑起来之后内部在干嘛?
就是一个循环:
循环每转一圈就是思维链上一个节点。问题在于 LangChain 的 能告诉你这些事件发生了,但它不给你一个结构化的状态对象来表达整条链的拓扑关系——你拿到的是一堆散装事件,得自己攒成一棵树。
一开始设计太复杂了后来砍了又砍,砍到不能再砍:(数据结构。踩了几次坑之后收敛出来的版本)
用 和 形成树结构。等下——不是说好了 DAG 吗?对,理论上如果两个 Tool 的结果同时喂给下一轮 LLM 决策那确实是 DAG 不是树。但在 LangChain.js 目前的 实现里(注意我说的是 不是 )并行 Tool 调用的结果最终还是拼成一条消息喂回去的,所以中间状态用树来建模够用了,真要严格 DAG 后面单独讲。
管理器,维护这棵树同时对接 LangChain 的 callback 体系:
为什么 不触发 ?
因为 GPT-4 和 Claude 吐 token 的速度大概每秒 30 到 80 个,短 token 飞起来的时候能到 100 以上——如果每个 token 都触发一次 React 你的 UI 线程会直接卡成幻灯片放映。正确做法是在消费端 throttle,用 一帧刷一次就够了:
在这里是有点奢侈的。节点多的时候每帧 clone 一次整棵树开销不小(虽然说实话 20 个节点的对象 clone 一次也就微秒级别),更好的做法是上 维护 immutable 结构,但过早优化不如先跑通再说。
写到这里突然觉得之前说的不太对。
接着要把 和 LangChain 的 callback 对接。继承 重写一堆 方法:
这段 handler 有一个 LangChain 做得不好的地方—— 的第二个参数 是 不是结构化对象,你得自己 ,而且它有时候给你的不是合法 JSON。不是 bug。是”特性”。(我已经在 GitHub issue 里看到过不下十个人吐槽这个事了,官方一直没改。)
串起来。启动代码:
到这一步思维链的数据流就通了,每一步推理每一次 Tool 调用都会在 里生成对应节点。
拉回来讲渲染。
这是整个方案里最容易做出来、也最容易做烂的部分。
先明确一下要渲染什么:
节点类型不统一,有 有 有 有 。连边方向单一但有并行分支。整个图是边跑边长的——这很要命。
用什么库?
核心挑战不在渲染。在布局算法。
每次新增节点整个图的布局可能要重算,如果用 做自动布局(react-flow 文档推荐的方式),每次 就重新算一遍所有节点的 坐标——已有节点位置会跳。用户正盯着某个节点看呢突然它蹦到另一个位置去了。体验极差。
我的方案是增量布局。新节点根据父节点位置做相对定位,已有节点纹丝不动:
坦白讲这段布局代码写得有点糙。并行分支水平展开的算法不太对,三个以上并行 Tool 的时候节点会挤成一坨n8n 工作流 教程——但 80% 的场景够用。再说吧。完美的 DAG 布局是一个学术级问题,Sugiyama 算法那一套你真去实现要写好几百行,在这个业务场景下追求完美属于浪费生命。你的用户关心的是”Agent 在干嘛””到第几步了””哪步挂了”,不是这图的 对不对称。
自定义节点组件,根据 渲染不同样式:
把 转成 要的 和 数组——BFS 遍历顺便算深度:
最终 React 组件:
踩坑提醒: 必须在 内部调用否则直接报错,而且这个 Provider 不能和 在同一个组件里——得包在外面一层,文档里写了但不显眼,十个人里九个半会踩这个。
还是 ?绕不开的选择。
LangChain 团队自己都在推 作为 Agent 编排的下一代方案, 某种意义上已经进维护模式了。 原生就是图结构—— 加节点加边——天然比 那个 循环模型更贴合 DAG 可视化的需求:
但 也不是万能药,它的学习曲线比 陡不少——、、、 一堆新概念砸过来,而且 JS 版本目前功能比 Python 版少了一截。如果你的场景就是一个简单的 ReAct 循环, 配上前面那套 callback 机制已经够使了,别为了架构上的”正确性”引入不必要的复杂度。能跑。够了。
性能方面最大的瓶颈根本不在前端渲染。
状态持久化这块, 的数据目前纯内存, 一下就全没了。如果需要回放历史对话的推理过程——企业场景里这个需求挺常见,审计合规什么的——得把整个 序列化存后端,每个事件带 ,回放时按时间戳重新 。这块展开讲又是一整篇文章的体量了。
跑了大半年生产环境,这套方案最大的教训就一句话:别想一步到位。 的 枚举我改了四版, 的结构加了三次字段,DAG 布局算法换过两种方案。先用 加最基础的 callback 加一个简单的列表式渲染跑通,确认产品方向没问题了再逐步往上堆 DAG 可视化、增量布局、流式 token 这些花活。想等一步到位只会等出个寂寞来。
发布者:全栈程序员-站长,转载请注明出处:https://javaforall.net/285108.html原文链接:https://javaforall.net
