AnyVault Agent 架构优化:解耦升级
AnyVault Agent 架构演进:从单步生成到端云多智能体
AnyVault 是一款让用户通过自定义结构记录和管理信息的跨端应用。为了让自然语言能够直接驱动结构设计、数据查询和界面操作,我们逐步把最初的一次模型调用演进为端云协作的多智能体系统。
本文用一条“创建收藏夹”的请求贯穿架构主体:
建收藏夹:帮我创建一个“吉他练习”收藏夹,记录练习时长、曲目和心情。
项目早期,系统可以调用一次大模型生成收藏夹结构,却不能判断任务类型,更谈不上自主选择工具和协调专业任务。
为支持这样一条请求,AnyVault 的 AI 后端先后经历了四个阶段:
单步结构化生成
→ 静态意图路由
→ 工具化 Agent
→ 端云多智能体
这里所说的 AI Agent,是指能够理解目标、选择工具并决定执行步骤的 AI 程序。这次演进并不是一个不断追逐新框架 API 的过程。每次升级都源于同一个信号:一项需求变化开始迫使多个无关模块同时修改。解决办法也始终一致:把控制权交给掌握相关信息、也承担相应责任的一方。
本文按照真实演进顺序,说明每一阶段解决了什么、为什么后来不再够用,以及下一阶段如何从问题中自然生长出来。
阶段一:单步结构化生成
先让模型输出 Android 能读懂的数据
AnyVault 最早要解决的问题很具体:把“帮我创建一个‘吉他练习’收藏夹”转换成可以直接渲染的收藏夹结构。
第一版流程只有一个模型节点:
用户需求
→ designer
→ AiCollectionResponse
这里使用了结构化输出(Structured Output,要求模型按照预定义字段返回数据),而不是让模型生成一段自由文本。Pydantic(Python 的数据校验库)定义 AiCollectionResponse 和字段类型,Android 只需按照同一份契约反序列化结果。
- System Prompt(系统提示词,用来规定模型的长期角色和规则)负责告诉模型如何设计字段;
- Few-Shot 示例(在提示词中提供少量输入输出案例)则展示一份合格的“吉他练习”收藏夹应该长什么样。
整个流程可以用 LangGraph 编译成 START → designer → END。LangGraph 是一个用“节点”和“边”描述 AI 工作流的框架,但此时我们只使用了它最基础的编排能力。这套实现本质上仍是单步 LLM Chain,也就是“一次输入、一次模型调用、一次输出”的处理链,还不是能够自主选择工具的 Agent。
Prompt 负责引导,代码负责兜底
最初,字段类型和选项数量等规则主要写在 Prompt 中。例如,CHOICE 字段应该提供 3~5 个候选项。
问题在于,Prompt 只能提高模型遵守规则的概率,不能提供确定性保证。模型仍可能漏掉必填字段、输出客户端不支持的类型,或者生成数量不符合要求的选项。
因此,规则需要按性质分层:
| 规则 | 负责人 | 示例 |
|---|---|---|
| 开放式设计偏好 | Prompt | 为“吉他练习”收藏夹推荐有价值的字段 |
| 数据类型和必填项 | Pydantic | fieldType 必须来自规定枚举 |
| 确定性业务约束 | 业务校验器 | CHOICE 必须包含 3~5 个选项 |
| 当前版本支持范围 | 客户端能力协议 | 旧版 Android 不能接收 AUDIO_MEMO |
校验失败后,系统把明确的错误交还给模型重新生成。这就是后来“生成—校验—修正”子图的起点:确定性规则归代码,模型只负责规则范围内的开放式设计。
单节点解决了“如何生成收藏夹”,却无法区分查询、修改和普通对话。当系统出现第二种任务后,我们首先采用了最直接的办法:在模型前增加一个意图分类器。
阶段二:静态意图路由
用小模型分类,用代码分发
这一阶段由一个轻量模型判断用户意图,再由业务代码进入固定分支:
用户输入
→ 意图分类器
→ create_collection / query_status / chat
→ 对应业务节点
不同请求被压缩成不同标签:
“创建一个‘吉他练习’收藏夹”
→ create_collection
“最近一个月我练了多久?”
→ query_status
“把完成度字段改成评分”
→ update_collection
这种“大模型分类、代码路由”的架构很适合最小可行产品(MVP,即用最少功能验证产品是否成立):路径确定、调试简单,也容易为每个分支编写测试。只有两三个意图时,显式条件判断比通用 Agent 更可靠。
功能增长后,路由开始牵一发而动全身
随着能力增加,每新增一种意图,往往要同时修改:
- State(工作流运行时保存的数据)中的意图枚举;
- 分类 Prompt 中的规则和示例;
graph.py中的条件边;- 下游节点和响应解析逻辑。
更关键的是,分类器只知道预定义标签,不知道系统中实际有哪些可执行能力。遇到同时包含多个动作的复合请求时,固定单标签分类也很容易被迫二选一。
当意图枚举逐渐变成一份人工维护的能力目录时,下一步就很自然:不再让模型输出分类标签,而是让它直接选择系统提供的工具。
阶段三:工具化 Agent
从意图标签升级为 Tool Calling
工具调用(Tool Calling,模型按照约定格式选择并调用外部函数)把系统能力直接暴露给模型。例如:
design_schema_tool:设计集合结构;query_status_tool:分析历史记录;ask_for_clarification_tool:信息不足时向用户追问。
主循环由此简化:
模型判断下一步
→ 请求工具:ToolNode 执行后把结果交还模型
→ 不请求工具:结束或进入统一响应节点
ToolNode 是 LangGraph 提供的通用工具执行节点,负责读取模型产生的工具请求、调用对应函数,再把结果写回消息历史。新增能力主要变成注册新工具,不再需要为每一种意图增加一套枚举和条件边。
对于贯穿全文的主案例,模型可以直接选择 design_schema_tool,不必先输出 create_collection 再由外层代码翻译成具体能力。其他请求也按需选择自己的工具。
快模型守门,慢模型完成专业生成
选择工具和设计复杂 Schema(数据结构定义,描述集合包含哪些字段及其类型)对模型能力的要求不同。如果所有请求都使用强模型,简单聊天会承担不必要的成本;如果全部使用轻量模型,复杂结构又容易生成失败。
因此,工具化 Agent 阶段采用快慢模型分工:
- 快模型负责理解意图、选择工具和发起澄清;
- 慢模型只在 Schema 设计或深度分析等复杂任务中启动。
快模型首先承担入口守门职责。如果用户只说“帮我建个收藏夹”,它会调用 ask_for_clarification_tool,询问用户想记录什么、最关注哪些信息;只有信息达到最低要求后,才调用 design_schema_tool 启动慢模型。主动澄清不是为了让对话显得更像真人,而是避免慢模型在缺少关键条件时盲猜。
为了避免快模型填写复杂字段,design_schema_tool 通过 Injected State(状态注入,由框架把当前工作流数据传给工具)获得对话内容,再在工具内部调用专用模型:
@tool
async def design_schema_tool(
state: Annotated[dict, InjectedState],
) -> dict:
messages = [
SystemMessage(content=DESIGNER_SYSTEM_PROMPT),
*state["messages"],
]
result = await structured_designer.ainvoke(messages)
return result.model_dump()
这段实现看起来只有一个工具函数,运行时却完成了一整条专家委托链路:
用户消息
→ 快模型判断是否需要设计收藏夹
→ ToolNode 执行 design_schema_tool
→ LangGraph 注入当前 State
→ 工具切换到 Designer Prompt 和慢模型
→ 慢模型生成强类型 Schema
→ 工具结果返回主循环
第一步,快模型只负责判断“该找谁”,不负责完成专业任务。由于 design_schema_tool 没有暴露复杂业务参数,它只需要发起一次不带业务参数的工具调用。
第二步,InjectedState 完成无感的上下文传递。state 不会作为普通工具参数要求快模型填写;真正执行工具时,LangGraph 才把当前消息和状态注入函数。快模型不必转述用户需求,慢模型可以直接读取原始上下文,减少中间转述造成的信息损失。
第三步,工具内部同时切换三项配置:
- 从负责分发的快模型切换到负责结构设计的慢模型;
- 从通用路由 Prompt 切换到
DESIGNER_SYSTEM_PROMPT; - 从普通对话输出切换到 Pydantic 约束的结构化结果。
因此,design_schema_tool 不是普通函数工具,而是 Model-Powered Tool(模型驱动工具)。快模型负责“是否调用”,慢模型负责“如何生成”,二者按任务难度分摊成本和能力要求。
这种分工降低了普通请求的成本,也把高难度结构化输出留给更合适的模型。但完整注入对话历史也有代价:Schema 工具会看到许多与收藏夹设计无关的消息。这个问题后来成为拆分专业 Agent 的原因之一。
用 SubGraph 隔离生成和纠错过程
Schema 生成已经不再是一次模型调用,而是一个完整循环:
生成草稿
→ Pydantic 与业务规则校验
→ 失败:根据错误修正
→ 成功:返回最终 Schema
如果草稿、校验错误和重试次数全部进入主图,Schema 领域的内部细节就会污染全局 State。我们因此把这段流程封装为 SubGraph(子图,在主工作流内部运行的独立小工作流):
- 主图只负责进入子图并接收最终结果;
- 子图维护自己的
SchemaState; - 失败草稿和校验反馈只在子图内部流转;
- 通过校验的结果才返回主图。
从阶段一回看,这条演进路径很清楚:Prompt 约束先下沉为代码校验,校验失败产生重试循环,循环拥有独立状态后被封装为子图。
阶段三走到了哪里
到这里,系统已经完成三层升级:Tool Calling 让能力可选择,快慢模型实现专业委托,SubGraph 隔离复杂任务的状态和重试过程。但这三层解决的并不是同一个问题:
| 组件 | 已经拥有的能力 | 仍然缺少什么 |
|---|---|---|
| 快模型 | 理解意图、澄清并选择顶层工具 | 承担所有领域路由和上下文 |
| One-Shot Specialist | 专用模型、Prompt 和输出契约 | 独立状态、工具选择和完成判断 |
| Schema SubGraph | 局部状态、生成校验循环 | 自主选择步骤和交还控制权 |
| Specialist Agent | 独立目标、状态、工具和完成判断 | 留到下一阶段实现 |
这里需要区分结构边界和决策边界:SubGraph 只说明一组节点、边和局部状态被封装起来;Agent 则意味着一个执行单元接收目标,并在权限范围内决定如何完成、何时结束以及把控制权交给谁。
当前 Schema 子图的路径仍由代码预先确定:
generate → validate → retry / finish
模型负责生成,校验器决定成功或重试。因此,独立 SchemaState 证明了状态隔离,却不能单独证明它已经成为 Schema Agent。反过来,一个 Agent 也不一定要实现成 SubGraph;只要它拥有独立决策闭环,即使框架把它实现为单个节点,仍然可以是 Agent。
按宽松定义,One-Shot Specialist 已经可以称为 Tool-Based Sub-Agent;按严格定义,它和固定 SubGraph 仍然缺少独立决策闭环。如果所谓 schema_agent 只是无条件调用 Schema 子图并返回结果,它更准确的名字是 Schema Worker 或 Schema Service,整体仍是“单 Agent + 多个工作流”。这个命名没有统一行业标准,重要的是不要把“用了多个模型”或“拆出了子图”直接等同于多 Agent。
此时真正的瓶颈也清楚了:主 Agent 虽然不再处理 Schema 内部细节,却仍然绑定所有工具、阅读所有领域说明并掌握最终控制权。随着 RAG、记忆和聊天能力增加,代码分支减少了,模型上下文却开始过载。只有当这些专业模块进一步获得自己的目标、工具和完成判断时,系统才需要进入显式多 Agent 协作。
阶段四:端云多智能体
从万能 Agent 拆成 Supervisor 和 Specialists
阶段四把上一阶段缺少的决策边界显式化:两个或更多拥有独立决策闭环的 Agent 通过契约协作,并明确控制权如何交接。
AnyVault 将原来的万能 Agent 拆成:
- Supervisor(主管 Agent):理解整体意图并分派任务;
- Schema Agent(表单专家):自主决定结构设计步骤,并把固定的生成校验流程作为内部子图使用;
- RAG Agent(检索增强生成专家):结合检索结果完成分析;
- Chat Agent(对话专家):处理普通聊天、澄清和记忆任务。
RAG 是 Retrieval-Augmented Generation 的缩写,中文通常称为“检索增强生成”:先从外部数据中找到相关内容,再让模型依据这些内容回答,而不是只依赖模型自身知识。
Supervisor 可以继续使用快速模型,但不再加载所有专业规则。每个专家只看到完成任务所需的 Prompt、工具和局部状态,并在自己的边界内判断信息是否充足、选择流程、评估目标是否完成。
对于贯穿全文的建收藏夹请求,Supervisor 把目标交给 Schema Agent,由后者独立完成结构设计、校验和修正。遇到查询本地收藏或普通聊天时,Supervisor 才会分别选择 RAG Agent 或 Chat Agent。多智能体系统并不要求每次请求都经过多个专家,大多数请求只需要一个 Specialist。
从隐藏的模型调用变成明确的任务交接
工具化 Agent 阶段的 design_schema_tool 在函数内部调用慢模型。虽然有效,但从主图观察,它仍然只是一段工具代码,看不出控制权已经交给了一个复杂专家。
端云多智能体阶段使用 Handoff(任务交接,即一个 Agent 明确把控制权转给另一个 Agent)表达这次委托。Supervisor 调用 transfer_to_schema_agent 等交接工具,系统据此进入对应专家节点。
这让运行轨迹能够直接回答两个问题:现在是谁在处理任务,完成后应该回到哪里。Handoff 和 Command 负责实现交接,但不会自动把普通节点变成 Agent。
Command 让状态更新和下一跳同时发生
早期图使用 Conditional Edge(条件边,根据 State 判断下一节点)表达动态路由。分支较少时很清楚;业务复杂后,graph.py 中的路由函数却开始解析节点返回的 JSON,再根据 status 等业务字段决定流向。
结果在 nodes.py 产生,含义却在 graph.py 解释。编排层开始理解 Schema 和 RAG 的业务细节,状态更新与路由决定也被拆成两步。
Command 是 LangGraph 中同时表达“更新什么状态”和“接下来去哪里”的返回对象。产出业务结果的节点最了解结果含义,因此可以直接返回:
return Command(
goto="schema_agent",
update={"messages": [handoff_message]},
)
调整后的边界是:
graph.py描述节点和稳定骨架;- 静态边表达确定、无条件的顺序;
- 节点解释自己产生的业务结果;
Command表达状态更新和动态下一跳。
Tool Calling 和 Command 解决的是不同问题:前者让模型选择能力,后者让节点决定执行结果如何改变流程。两者在交接工具中结合起来。
到这里,云端内部的职责已经拆开。下一个耦合点不在 Agent 之间,而在云端与 Android 的边界:云端不知道客户端支持哪些字段,也无法直接访问只保存在设备上的数据。
把解耦边界延伸到 Android
客户端声明能力,服务端负责授权
过去,System Prompt 会写死 Android 支持 TEXT、NUMBER 和 RATING 等字段。客户端新增 LOCATION 或 AUDIO_MEMO 后,即使功能已经实现,仍要等待云端同步更新。
客户端最了解自身版本,因此每次请求携带能力清单:
{
"query": "帮我创建一个‘吉他练习’收藏夹",
"client_capabilities": {
"app_version": "2.4.0",
"supported_fields": [
"TEXT",
"NUMBER",
"RATING",
"LOCATION",
"AUDIO_MEMO"
],
"supported_views": ["KANBAN", "LIST"]
}
}
Schema Agent 只生成当前客户端能够渲染的结构。新能力可以随客户端版本生效,云端不必复制一份完整的 UI 能力表。
能力声明不等于权限授权,客户端上报也不能直接作为可信输入。最终可用能力应当取客户端声明、当前协议允许范围与服务端授权范围的交集。客户端可以声明自己支持定位字段,服务端仍然负责身份、参数、灰度开关和工具权限校验。
UI 上下文帮助 Agent 理解“这个”
能力清单回答“客户端能做什么”,UI Context(界面上下文,即用户当前所在页面和选中对象)回答“用户正在看什么”:
{
"current_context": {
"screen": "CollectionDetail",
"collection_id": "guitar_practice"
}
}
当用户说“给这个收藏夹增加一个心情评分字段”时,Supervisor 可以根据当前页面确定“这个收藏夹”指什么,不必再次询问用户要修改哪份结构。
客户端提供界面上下文声明,Agent 负责判断意图,服务端则校验对象是否存在以及当前用户是否有权访问。这三者不能互相替代。
把本地 RAG 变成异步工具调用
端云 RAG 不再沿用创建收藏夹的主案例,而是使用一个只属于本节的查询请求:
用户已经在“吉他练习”收藏夹中积累了三个月的收藏条目,现在询问:“最近一个月我的练习状态怎么样?”
这些收藏记录保存在 Android 本地,云端 RAG Agent 无法直接读取。早期链路使用二段式请求:
第一次请求:用户问题
→ 云端返回 require_local_search
→ Android 完成本地检索
→ 第二次请求携带原问题和 local_context
→ 云端生成答案
这套方案实现了按需上传,但第二次请求在服务端看来是一轮新对话。为了阻止模型再次触发检索,客户端一度需要把控制信息包装进用户消息:
【系统指令:已获取本地数据】请根据这些数据回答用户的原始问题……
用户意图、工具结果和流程信号由此混在一起。消息历史受到污染,Supervisor 也可能把恢复请求误判成新请求,再次下发检索。
端云多智能体链路将 Android 视为 Tool Executor(工具执行端,即真正运行工具并返回结果的一方),把本地检索建模为一次跨网络的异步工具调用:
HumanMessage:用户原始问题
→ AIMessage(tool_calls):模型请求本地检索
→ interrupt():云端暂停工作流
→ Android:检索并等待用户确认
→ Command(resume=...):恢复原工作流
→ ToolMessage:写入检索结果
→ RAG Agent:生成分析
Interrupt 是 LangGraph 的中断机制,用于保存当前位置并暂停执行;Resume 是恢复动作,让后续请求从原位置继续,而不是重新开始一轮对话。
等待节点的核心逻辑如下:
async def wait_for_client_node(state: AgentState) -> Command:
tool_call = state["messages"][-1].tool_calls[0]
client_response = interrupt(tool_call)
tool_message = ToolMessage(
content=client_response["content"],
tool_call_id=tool_call["id"],
name="client_local_search",
)
# 关联标识取自服务端检查点,避免客户端伪造其他会话的工具结果。
# 意图在挂起前已经确定,直达 RAG 可避免恢复请求被当成新问题再次路由。
return Command(
goto="rag_agent",
update={"messages": [tool_message]},
)
恢复后不再经过 Supervisor,因为任务类型在中断前已经确定。用户说了什么保存在 HumanMessage,模型请求什么保存在 AIMessage.tool_calls,客户端返回什么保存在 ToolMessage,控制流和数据流不再混用。
生产环境不能信任客户端回传的恢复标识和检索内容。服务端需要校验用户、会话、thread_id 与未完成 Interrupt 的归属关系,从检查点取得 tool_call_id 和工具名称,并限制重复恢复、结果结构与数据大小;本地数据上传前还应获得授权并完成最小化和脱敏,进入模型后只能作为待分析数据,不能覆盖 System Prompt 或改变工具权限。
要让中断跨越请求甚至服务实例恢复,系统需要 Checkpointer(检查点存储器,负责持久化工作流状态)。我们曾遇到本地正常、云端 state.interrupts 丢失的问题,具体排查见《LangGraph Interrupt 云端失效:Checkpointer 的序列化陷阱》。
渐进升级:内部变化不能破坏客户端契约
架构升级不能要求所有历史客户端同时更新。入口网关根据客户端协议版本选择链路:旧客户端继续进入兼容流程,新客户端进入多智能体图和原生中断流。
版本判断使用普通代码完成。协议版本是确定性事实,不应该增加额外的模型调用成本,也不应该交给模型猜测。
新链路内部可以使用不同的 State 和消息类型,最终仍由 format_response_node 转换成稳定的客户端契约。流式体验也随执行模型调整:过去监听工具启动事件,拆分后可以监听 Schema Agent 或 RAG Agent 的节点事件,继续向客户端发送统一的处理中状态。
回看四个阶段
| 阶段 | 核心模式 | 解决的问题 | 暴露的新瓶颈 |
|---|---|---|---|
| 单步生成 | 结构化输出 | 跑通自然语言到强类型结构的生成 | 只能处理一种任务,Prompt 无法保证业务规则 |
| 静态路由 | 意图分类 + 条件分支 | 快速扩展少量确定性业务 | State、Prompt 和 Graph 同步膨胀 |
| 工具化 Agent | Tool Calling + 快慢模型 + SubGraph | 扩展能力、控制成本、隔离复杂任务 | 万能 Agent 的工具和上下文过载 |
| 端云多智能体 | Supervisor + Specialists + Command + Interrupt | 解耦职责、控制流和端云执行 | 对状态持久化提出更高要求 |
贯穿四个阶段的不是某个框架功能,而是一组逐渐清晰的所有权关系:
| 变化来源 | 控制权归属 |
|---|---|
| Android 支持的字段、视图和当前界面 | 客户端能力协议 |
| 用户意图应该交给哪个领域 | Supervisor |
| 专业任务如何推理和纠错 | Specialist 与内部 SubGraph |
| 业务结果对应哪条动态路径 | 产出结果的节点与 Command |
| 本地数据如何执行并返回 | Tool Call、interrupt() 与 ToolMessage |
| 内部结果如何兼容客户端 | 统一响应网关 |
| 跨请求状态如何恢复 | Checkpointer |
总结
回到“创建吉他练习收藏夹”的请求,它从一次直接模型调用,先后经历静态分类、工具选择、快慢模型分工和子图隔离,最终成为 Supervisor 与 Schema Agent 之间的一次明确任务交接。每个阶段都曾是当时复杂度下的合理选择,只有旧边界开始阻碍新需求时,升级才有必要。
判断架构是否需要继续升级,关键不是框架有没有发布新的 API,而是一次需求变化是否迫使多个无关模块同时修改。当 Android 新增字段、Schema 调整校验策略或 RAG 更换持久化实现时,如果其他层不再被迫同步变化,架构才真正完成了解耦。

浙公网安备 33010602011771号