Agent 与业务后端拆分,边界应看状态归属
文章主张由 Agent 服务保存 LangGraph 执行检查点,由业务后端保存页面恢复所需的业务记录,更新通过生成的 WebSocket 客户端单向传递。这样可让图节点脱离连接和用户上下文,直接用构造状态进行测试。
AI 深度解读
Agent 系统要不要拆服务,难点往往在拆哪里。作者给出的界线是状态归属:业务后端保存用户能看到、刷新后还要找回的记录,Agent 保存图执行和恢复所需的状态。
这套方案的价值首先是可测试:一次路由决策能不能脱离连接、登录和数据库单独运行?文章也记录了一次模型记忆双写踩坑,对正在把聊天原型做成正式产品的团队尤其有参考价值。
拆服务最直接的收益,是终于能测到那次决策
作者交付过单服务版本。它没有突然崩掉,而是在一个个合理需求里逐渐耦合。
Agent 要判断客户是不是付费用户,于是 prompt 里加了套餐规则。计费系统新增年度套餐后,提示词却没有同步约束。图节点要查工单历史,于是直接调用 ORM。随后,运行一次图测试也需要数据库和测试数据。等模型请求慢到阻塞 Web worker,团队想独立部署 Agent,才发现依赖已经缠在一起。
问题向两个方向蔓延:业务规则进入提示词,节点执行细节进入业务表。前者缺少与计费模型同步的校验,后者让应用数据结构背上图运行时的负担。
测试会先暴露这种耦合。假如路由决策藏在 WebSocket consumer 内,断言只有一行,前面却得建立连接、认证用户、准备会话和工单。模型调用又散落在对象依赖里,mock 越补越多,最后很难判断究竟测到了什么。
作者给出的完整测试,展示了如何直接调用图并验证知识库分支:
async def test_knowledge_base_branch_grounds_the_answer() -> None:
thread = "kb-branch"
with mock_openai(CLASSIFY, SEARCH_KB, ANSWER_FROM_KB) as route:
state = (
await SupportGraph()
.compile(checkpointer())
.ainvoke({"context": ticket()}, config=config(thread))
)
assert route.call_count == 3
assert isinstance(state["decision"], SearchKnowledgeBase)
# 检索步骤已经执行,并把结果交给回答 Agent。
assert state["kb_snippets"]
assert any("kb-002" in snippet for snippet in state["kb_snippets"])
留意 mock_openai(...):作者只替换模型的 HTTP 响应,三次模型调用对应的流程、检索和路由仍然执行。kb-002 断言则检查检索结果是否进入状态。不过,这段代码调用的是编译后的完整图,并非孤立的单节点测试。
可测试性的关键,是能构造输入状态并直接调用决策逻辑。
按状态划界,才能决定数据该放哪边
作者把状态分成两类。
Agent 持有执行状态:接下来运行哪个节点、当前通道值、等待人工处理的中断载荷。在 LangGraph 中,这些内容由 checkpoint 保存,参考实现将其存入 Postgres。它包含运行时恢复需要的信息,不能靠拼几条业务记录手工还原;丢失后,原本暂停等待审批的执行就无法照常恢复。
后端持有业务记录:消息、工单事件、等待审批的回复草稿,以及待确认的工具调用。这些记录让页面刷新后仍能展示审批卡片。草稿出现在两边并不代表职责重复:一边用于恢复执行,一边用于恢复产品界面。
因此,权限、计费和用户记录归后端,prompts、tools 和 routing 归 Agent。产品后端不导入 LangGraph。参考实现共三个服务、四个进程,执行由后端代码实现的 worker 从队列取任务驱动;原文没有完整列出服务清单,不能进一步补全架构。
作者还踩过一次模型记忆的坑。用户看到的会话记录,与 Pydantic AI 使用的 message history 不是同一种数据。后者包含工具调用及结果、此前结构化输出的形状和消息顺序,压成普通聊天文本会丢信息。
最初,他们先保存消息,再由后台任务写入另一张模型记忆表。排队、重试或任务未到达,就可能让两份数据不一致,而且没有可靠的重建方向。
修正后,模型记忆进入图状态,与 checkpoint 一起保存;没有记忆时,再从后端传来的记录开始。丢失 checkpoint 仍会损失恢复能力和历史工具调用细节,但不会丢掉用户会话本身。
这里可以提炼出一个判断:丢了就丢业务数据的内容,应进入后端记录;只影响效率或上下文还原程度的内容,可以留在 Agent。
用单向事件和生成契约守住边界
更新沿 Agent 到后端的方向传播:图节点广播事件,后端 relay 接收后,一边保存业务记录,一边转发给浏览器。两边不直接读写对方的表。这里的「单向」指结果更新,运行请求和审批指令仍需要后端发给 Agent。
不同事件有不同落库语义。终止事件保存本轮结果,暂停执行保存审批卡片;工具提案不会直接成为一次已执行的会话轮次,只有真正运行后才算。
后端用 Sink 表达事件落地接口,包括 classified、decided、drafted、approval_required、reply_sent、reply_blocked、token、answered、tool_proposed、tool_ran、reasoning_delta、reasoned 和 failed。
面向客户的工单、内部团队工单和独立会话,共用连接逻辑,只替换各自的 Sink。不关心 token 增量的调用方可以不实现 token。测试 relay 时,也可以传入只记录调用的 Sink,检查它准备写什么。
跨服务消息则通过生成代码约束。Agent consumer 输出 AsyncAPI 文档,后端导入生成的 Python 客户端 AgentHubSupportTopicClient,以及 ApprovalRequiredMessage、ReplySentMessage、RunRequestMessage、ToolApprovalMessage 等消息类型。
修改消息后重新生成,不兼容的后端用法就能在类型检查时暴露。思路与 OpenAPI 生成 REST 客户端相似。原文没有提供生成命令,也没有展开兼容部署策略。
还有一道必须落实的边界:浏览器只连接后端,Agent 留在内部网络。因为审批关卡在图内,能直接连接 Agent socket 的调用方可能同时拥有提案和批准能力。作者将网络隔离作为主要控制,共享 token 作为第二道控制;正式环境不能省略 token。
我的判断
我赞同先明确状态归属。它能回答审批卡片、工具提案、模型记忆这些具体对象应该放哪里,比按技术层次拆目录更有操作性。
但可测试性也能通过模块边界获得。原文展示了混写实现的问题,以及拆分后的测试,没有提供同进程模块化方案的对照数据。它证明了作者方案可行,还不足以证明所有团队都该拆进程。
「记录可以滞后,但不会分叉」也需要条件。单向事件减少了双向同步的冲突,仍需处理事件丢失、重复、乱序和写入失败。原文没有交代重放、幂等或投递保证,不能把单向传播直接视为一致性保证。
成本同样真实:两套服务配置、需要匹配的共享 token、worker、web app、两次部署和两组日志。作者认为模型调用耗时以秒计,本地 socket 的几毫秒可以忽略,这是特定负载下的经验判断,没有基准对照;高频、短耗时的 Agent 可能不同。
原型或只有自己使用的工具,单服务合理。独立扩容、Web worker 阻塞、多人协作冲突,以及业务表开始出现图节点字段,才是更明确的拆分信号。
能拿来做什么
- 先检查一次路由决策的测试前置条件。如果必须登录、连接 socket、准备整套数据库,优先把决策提取成接收明确状态的函数,再评估是否需要独立部署。
- 为聊天和审批页面列出刷新后必须恢复的内容,把消息、草稿、工具提案作为业务记录保存,并明确区分「已提案」「待审批」和「已执行」。
- 检查模型记忆是否与会话记录分别异步写入。如果存在双写,先明确哪份数据是事实来源,以及另一份能否从它重建。
- 准备拆服务时,把消息契约生成与类型检查纳入变更流程,同时补齐事件重复、丢失和审批失败的处理,避免只完成部署层面的拆分。