Langfuse In-App Agent 架构深度解析:单一事件日志与三种派生视图的工程契约
【免费下载链接】langfuse🪢 Open source AI engineering platform: LLM evals, observability, metrics, prompt management, playground, datasets. Integrates with OpenTelemetry, LangChain, OpenAI SDK, LiteLLM, and more. 🍊YC W23项目地址: https://gitcode.com/GitHub_Trending/la/langfuse
Langfuse 的项目级内嵌 AI 助手(In-App Agent)采用"浏览器观察、Worker 持久化执行"的架构:用户在产品 UI 中与助手对话,真实的一次次运行(run)在后台 Worker 中持续执行,浏览器只负责观察其持久化的事件流。本文以web/src/features/in-app-agent/ARCHITECTURE.md为骨架,结合packages/shared、web、worker三端源码,系统讲解"一条日志、三种派生"(one log, three derivations)的表示契约、fold once渲染不变量、代码职责边界、持久化执行链路以及改动的红线规则。读完你不仅能理解抽屉式聊天界面的构建动机,还能掌握让浏览器、持久化转录与运行生命周期保持一致的工程方法论。
为什么抽屉这样构建:单一事实来源的三种派生
In-App Agent 的架构核心可以浓缩为一句话:对话的每一种消息表示,都从唯一一个地方派生而来——in_app_agent_events表,并按照每个会话(conversation)内的sequence_number排序。规范消息(canonical)、展示消息(display)与回放消息(replay)不会被分别持久化成多个独立的转录事实来源。
| 派生视图 | 生产者 | 消费者 | 形态 | 永不应该出现的地方 |
|---|---|---|---|---|
| Canonical messages(规范消息) | createConversationMessageAccumulator(位于 packages/shared/src/in-app-agent/server/persistence.ts) | getConversation线(wire)、AG-UI 种子(seed)、反馈身份、标题推断 | 以稳定的 AG-UI 消息 ID 为键的完整 assistant 消息,保留runId,保留进行中的工具调用(in-flight tool calls) | 在给实时 agent 播种(seed)之前被裁剪(prune)或重排(reorder) |
| Display state + projection(展示状态与投影) | web/src/features/in-app-agent/lib/display.ts 中的record*累积函数与projectInAppAgentMessagesForDisplay折叠 | 仅在InAppAiAgentProvider中渲染一次 | 一个"边车"(sidecar),描述交错出现的推理与工具调用应插在何处,外加合成的display-text-<id>-N分段 | 写回服务器,或喂给 agent |
| Replay messages(回放消息) | getConversationMessagesForReplay(位于 packages/shared/src/in-app-agent/server/persistence.ts) | 恢复运行(resuming run)时的模型上下文 | 剥离了 reasoning、redirect 结果、runId以及未配对工具调用的消息 | 被渲染,或用作客户端水合(hydration)快照 |
三种派生的存在是因为它们回答的问题完全不同:发生了什么(what happened)、应该长什么样(how it should look)、模型下一步应该看到什么(what the model should see next)。把其中任意两个混淆,正是这个功能反复踩坑的失败模式,所以上面这张表不是描述,而是契约(the contract)。
从源码可以印证这一点:getConversationMessagesForReplay在 persistence.ts 中先取规范消息,再调用sanitizeConversationMessagesForReplay进行净化——过滤掉reasoning角色消息、丢弃 redirect action 的工具结果、剥离未配对的 assistant 工具调用、删除空 assistant 消息、并去除 assistant 消息上的runId(见 persistence.ts)。同一份事件日志,通过不同的折叠与净化管线,服务三类完全不同的消费方。
核心不变量:fold once(只折叠一次)
展示投影(display projection)恰好执行一次,且只在浏览器渲染时执行。这不是风格偏好,而是正确性要求。
投影为何是"有损"的
投影在一个特定意义上是有损的:它会在 assistant 消息的第一个交错块(interleaved block)处截断该消息,并把续文移动到一个合成的兄弟消息中。这对渲染是正确的,但对其他任何用途都是错误的。
文档记录了一个真实踩过的坑:该功能最初上线时,服务器也做了投影,于是浏览器在启动实时 AG-UI agent 时,用被截断的消息作为种子。AG-UI 按消息 ID 追加TEXT_MESSAGE_CONTENT,所以恢复运行的下一段增量落到了被截断的种子上,续文从规范转录中消失,直到运行结束并重新水合才恢复。
当时的修复不是换一个访问器,而是停止折叠两次。现在的线(wire)上携带的是"规范消息 + 作为边车的展示状态",唯一的一次折叠发生在实时路径原本就会折叠的地方——即浏览器渲染时。
两个必须明确写出来的推论
- 裁剪(pruning)本身也是一种展示行为。
dropUnpairedAssistantToolCalls与dropEmptyAssistantMessages(均位于 packages/shared/src/in-app-agent/messages.ts)只在渲染时、且只针对已settled(结束)的转录执行。实时种子必须保留进行中的工具调用,否则后续到达的TOOL_CALL_RESULT没有可挂靠的对象,AG-UI 会追加一条孤儿的tool消息,被抽屉静默丢弃。 - 边车是派生数据,绝不权威。即使边车丢失,转录依然正确,只是变得更"扁平"。因此
deserializeInAppAgentDisplayState(lib/display.ts)在反序列化失败时回退为空状态而不是抛异常。
在 display.ts 的projectInAppAgentMessagesForDisplay中可以看到投影的具体形态:它遍历规范消息中的 assistant 工具调用,依据展示状态中的toolCallPlacements生成display-tool-<id>合成消息,再依据textByMessageId中的分段生成display-text-<id>-N文本段——这些全部是边车状态,规范消息本身始终不动。
代码在哪里:shared / web / worker 的职责边界
代码布局遵循一条清晰原则:packages/shared只放 web 与 worker 都需要的东西;仅 web 需要的逻辑即使运行在服务器上,也留在 web。
- worker 运行 agent,绝不渲染。因此 Mastra 适配、工具、埋点、续跑处理、prompt 加载、技能(skills)与沙箱提供者都放在
worker/src/features/in-app-agent/runtime/。 - shared 只拥有跨进程的持久化契约与存储行为:事件日志、规范消息累积、回放、生命周期、审批事件、MCP 策略、工具结果脱敏(tool-result redaction)以及种子 prompt。shared 的持久化层对渲染一无所知。
- web 拥有:watch 帧(watch framing)、展示记录(display recording)、投影、ID、反馈/来源 schema,以及会话访问。
两个消息裁剪辅助函数是有意为之的例外:dropUnpairedAssistantToolCalls与dropEmptyAssistantMessages裁剪的是AgUiMessage形状而不是描述展示样式,且回放需要从 worker 侧调用它们,所以它们放在 packages/shared/src/in-app-agent/messages.ts 中,被回放净化与渲染时沉降(settling)两处共用。
持久化执行路径:一条贯穿浏览器、服务器与 Worker 的链路
ARCHITECTURE.md 用一张 mermaid 流程图勾勒出完整链路:
链路中的关键语义:
- 浏览器侧的
BackgroundExecutionSessionController通过 tRPC 发起start并获取快照; - web 服务器(
router.ts/backgroundRunService.ts)入队(enqueue)到 Worker; - Worker 中的
executeInAppAgentRun驱动runtime/agent + tools + sandbox执行,并写入in_app_agent_events事件日志; - 浏览器通过 SSE 从持久化的游标(cursor)之上续接 tail(
watch/route.ts); - 浏览器拿到"规范消息 + displayState"边车后,投影并平滑渲染到抽屉。
关闭抽屉只是分离观察,并不会取消运行(closing the drawer detaches observation without cancelling the run)。重新打开时,先水合一个快照,再从持久化的游标之上恢复 tail。会话(session)是浏览器中规范转录、审批、附件、取消与当前运行状态的唯一所有者(the sole owner)。
变更规则:给未来的改动划红线
ARCHITECTURE.md 给出了四条不可逾越的改动纪律:
- 新增一种表示 = 在上面的表格中新增一行,并写清它的生产者、消费者、以及它"永不应该出现的地方"。如果填不满这几项,说明你很可能只是在伸手够一个已经存在的表示。
- 在通往实时 agent 的路上,不要投影、裁剪或重排消息。种子必须保持完整。
- 让快照与 tail 停留在同一个游标上(Keep the snapshot and the tail on one cursor)。
- 在改动任何涉及排序、压缩(compaction)或持久化的代码之前,先读
README.md中关于 AG-UI 事件语义的部分。
与 README 的配合阅读:操作视角与实现视角的互补
web/src/features/in-app-agent/ARCHITECTURE.md与其同目录的 README.md 分工明确:
- README 是操作指南:每个文件拥有什么、一次运行如何流转、沙箱与 MCP 授权如何工作;
- ARCHITECTURE 解释无法从任何单个文件推断出的部分:为什么表示层长这样、折叠不变量从何而来、代码为什么分布在这三个包中。
例如 README 补充了运行生命周期(提交一条用户消息 → 会话安装持久化规范消息 + 展示状态 + 游标 → 快照独占消息/审批/运行/取消/附件 → 关闭抽屉调用detach()只停止观察)、客户端状态归属(React Query 拥有列表与持久化会话查询;BackgroundExecutionSessionController拥有实时执行事实;展示节奏保持在useSmoothStreamingMessages,规范 AG-UI 消息绝不为动画而重写),以及一套固定的生命周期策略常量(队列超时 300000 ms、最大运行时长 900000 ms、审批 TTL 86400000 ms,均为 web 与 worker 共享的常量,确保两端不会发散)。
总结
Langfuse In-App Agent 的架构设计可以浓缩为三个相互支撑的决策:
- 单一事件日志(
in_app_agent_events+ 会话内sequence_number)作为唯一事实来源,canonical / display / replay 三种视图全部由它派生,互不独立持久化; - 只折叠一次:有损的展示投影只在浏览器渲染时执行一次,线(wire)上传输的是"规范消息 + 边车展示状态",从根上杜绝服务器与浏览器双重折叠导致的转录丢失;
- 清晰的跨进程职责边界:shared 只持有持久化契约与存储行为,worker 执行永不渲染,web 拥有渲染、投影与观察;让"发生了什么、该长什么样、模型该看什么"三个问题各有各的答案。
这套契约不仅保证了抽屉界面与后台持久化运行之间的连贯性,也为后续任何新增表示或改动提供了可校验的边界——如果新增的表示无法填满"生产者 / 消费者 / 永不出现的位置"三栏,它大概率就是多余的。
【免费下载链接】langfuse🪢 Open source AI engineering platform: LLM evals, observability, metrics, prompt management, playground, datasets. Integrates with OpenTelemetry, LangChain, OpenAI SDK, LiteLLM, and more. 🍊YC W23项目地址: https://gitcode.com/GitHub_Trending/la/langfuse
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考