Agent 项目系统架构设计
比较 Agent 的抽象层级与逻辑分层,说明运行时、工具、状态、授权和可观测性如何协作,以及原型向生产系统演进时的取舍。
[!info] related notes
- 所属 MOC: Agent MOC
- 前置概念: Agent, Agentic Systems, Agent Runtime, Agent 执行闭环
- 并列概念: Agentic Workflow Patterns, 面向 Agent 的工具设计, AI Agent Memory Layer
- 关系笔记: AI Agent 创建实践, Python Agent API 服务, 多服务架构
- 参考来源: Anthropic - Building Effective Agents, OpenAI Agents SDK, Vercel AI SDK, Langfuse
Agent 项目系统架构设计
一句话定义
Agent 项目系统架构是把 LLM、工具、状态、记忆和可观测性组装成一个可运行、可维护、可演进的生产系统的分层设计方法——核心决策不是”用什么框架”,而是”从哪一档抽象开始”。
第一步决策:三档抽象选型
在画架构图之前,先回答一个问题:你的任务需要多复杂的 agent?
| 档位 | 抽象层级 | 代表实现 | 适用场景 |
|---|---|---|---|
| Raw API | 直接调用 LLM API + tool schema | OpenAI Responses API, Anthropic Messages API | 单轮任务、最大控制权、快速验证 |
| Managed Loop | 框架管理执行循环 + 工具分发 | OpenAI Agents SDK, Vercel AI SDK | 标准 tool-use agent、多轮对话 |
| Graph | 显式状态机 + 条件路由 | LangGraph StateGraph | 复杂分支、循环、human-in-the-loop |
[!note] 选型依据 Anthropic 建议从能解决问题的最简单方案开始,直接 API 调用是一个起点。是否引入框架取决于控制流、恢复、工具编排与团队维护成本,没有“超过 5 个工具才升级”的通用阈值;Graph 也适合无环的复杂分支。
先用代表性任务验证方案,再比较框架引入的收益与调试成本。下文六层是设计视角,不是必须逐项部署的产品清单,也不表示本仓库已有这些服务。
六层架构(当需要完整系统时)
一旦确定了抽象档位,完整的 agent 项目可以拆成六层:
┌─────────────────────────────────────────────────────┐
│ API Gateway │
│ 认证 · 限流 · 请求路由 · 响应格式化 │
├─────────────────────────────────────────────────────┤
│ Agent Orchestrator │
│ 多 agent 路由 · 任务拆分 · 协作编排 │
├─────────────────────────────────────────────────────┤
│ Agent Runtime │
│ 执行循环 · 状态机 · 停止条件 · 审批检查点 │
├─────────────────────────────────────────────────────┤
│ LLM Provider Layer │
│ 模型抽象 · 流式输出 · 重试 · token 计量 │
├─────────────────────────────────────────────────────┤
│ Tool & Integration Layer │
│ 工具注册 · 执行沙箱 · 结果标准化 · 错误映射 │
├─────────────────────────────────────────────────────┤
│ State & Persistence Layer │
│ 会话状态 · 工作记忆 · 对话历史 · 长期记忆 · 向量存储 │
└─────────────────────────────────────────────────────┘
↕ 所有层依赖 ↕
Observability Layer (tracing · 指标 · 评估 · 成本)
关键约束:这张图表达职责,不定义严格的栈式调用顺序。Runtime 通过接口依赖模型、工具和持久化能力;业务规则避免耦合具体传输或供应商 SDK。Observability 是横切关注点,可通过适配器或注入接入。
各层职责与设计要点
1. API Gateway
职责:对外暴露 HTTP/WebSocket/SSE 端点,处理认证、限流、请求分发。
设计要点:
- Gateway 可以统一认证,但下游必须验证身份上下文的可信来源,并在业务与工具边界检查租户、资源和动作授权;不能信任可由外部伪造的用户 header
- 对已获准公开的事件,Gateway 可透传 SSE 并避免无必要缓冲;必须完整校验的内容不能提前泄露
- 请求体做 schema 校验,非法请求在入口就拒绝,不要让脏数据深入 runtime
- 每个请求带 request ID,贯穿所有下游日志
反模式:
- ❌ 把 agent 逻辑直接写在 HTTP handler 里
- ❌ gateway 层做业务决策(应该路由到哪个 agent)
2. Agent Orchestrator
职责:决定”这个任务交给谁”——单 agent 直接执行,多 agent 时做任务拆分和协作编排。
五种编排模式及选型:
| 模式 | 机制 | 最佳场景 | 复杂度 |
|---|---|---|---|
| Manager-as-tools | 主 agent 把子 agent 当 tool 调用 | 层级任务、专家分工 | 低 |
| Handoffs | agent A 主动移交控制权给 agent B | 客服分流、多领域助手 | 中 |
| Sequential pipeline | 固定顺序,前一个的输出是后一个的输入 | 研究→起草→审核→发布 | 低 |
| Orchestrator-workers | 中央 LLM 动态拆解任务、分配给 worker | 无法预先确定子任务 | 中 |
| Graph | 有向图 + 条件边,支持循环和分支 | 复杂状态机、HITL 检查点 | 高 |
[!tip] 选型建议 固定依赖可先用顺序调用;中心协调者调用专家时可考虑 Manager-as-tools;需要交接对话控制权时考虑 Handoffs;动态拆解适合 Orchestrator-workers;复杂条件分支、恢复与人工介入可考虑 Graph。
单 Agent 与多 Agent 应在相同任务上比较质量、成本和失败恢复。工具数量本身不能证明“1 个 15 工具的 Agent 优于 3 个各 5 工具的 Agent”;状态共享、职责隔离和协调成本都影响结果。
什么时候不需要这层:
- 只有一个 agent,没有路由需求
- 用 Dify/LangGraph 等框架,框架本身承担了编排职责
3. Agent Runtime
职责:驱动单个 agent 的执行闭环——接收输入、调用 LLM、分发工具调用、收集结果、判断是否继续。
设计要点:
- runtime 是一个状态机,不是一段线性代码。核心循环:
以下接口与消息转换是伪实现,用于说明循环;实际 SDK 的消息格式、持久化和错误处理需由适配层实现。
async def run(self, input: AgentInput) -> AgentOutput:
state = self.init_state(input)
for turn in range(self.max_turns):
llm_output = await self.call_llm(state.context)
state.context.append(self.assistant_message(llm_output))
if llm_output.has_tool_calls:
tool_results = await self.execute_authorized_tools(llm_output.tool_calls)
state.context.extend(self.tool_result_messages(tool_results))
else:
return self.finalize(llm_output)
return self.force_stop(state) # 硬上限,不依赖模型自行停止
- 停止条件必须显式定义:模型输出了最终回答?达到最大轮次?token 预算耗尽?触发安全阀?
- 需要审批的动作在执行前等待批准,批准绑定动作和参数;工具或领域服务仍需在产生副作用时执行资源授权与状态校验
- runtime 不应该知道具体工具的实现细节,只通过 Tool Layer 的统一接口调用
- 交互任务可用 streaming 改善等待体验;后台任务、原子结果或需完整校验的内容也可一次性交付。先定义哪些事件与内容允许公开
关键接口:
class AgentRuntime:
async def run(self, input: AgentInput) -> AgentOutput:
"""一次完整的 agent 执行,从输入到最终输出"""
...
async def step(self, state: AgentState) -> AgentState:
"""单步执行:调 LLM → 处理输出 → 更新状态"""
...
4. LLM Provider Layer
职责:抽象底层模型调用,屏蔽不同 provider 的 API 差异。
设计要点:
- 统一接口:不管用 OpenAI、Anthropic 还是本地模型,调用方式一致
- 需要流式交互时保留增量事件接口;首事件延迟与完整任务完成时间是不同指标
- 按 provider 的事件协议,以调用 ID、item ID 或 index 关联工具增量,完整组装并校验参数后再执行;不能只取最后一块或混合不同调用
- 按错误码、错误类型与 Retry-After 决定重试:临时限流/5xx 可有界退避,配额耗尽或无效参数需先修复;遵守总预算,避免重复副作用
- token 计量在这一层完成,向上层暴露
usage信息,让 runtime 可以做上下文裁剪决策 - model fallback chain:仅在已批准且兼容的候选中切换,记录实际模型、能力差异与 fallback 原因
关键接口:
class LLMProvider(Protocol):
def chat_stream(
self,
messages: list[Message],
tools: list[ToolDef] | None = None,
) -> AsyncIterator[StreamChunk]:
"""流式返回,每个 chunk 可能是 text delta 或 tool_call delta"""
...
5. Tool & Integration Layer
职责:注册、执行、标准化所有外部工具的调用。
设计要点:
- 工具注册是声明式的:name、description、parameters schema、execute function
- 工具执行结果标准化为统一格式:
{success, data, error, metadata} - 每个工具有独立的超时和重试配置,不是全局一刀切
- 工具依赖通过显式参数或受限上下文传入,避免读取 Agent 隐式全局状态;写库、发消息等工具有副作用,并非纯函数
- 对 policy 要求审批的动作,由 runtime 协调审批;工具执行边界仍校验授权、参数与当前状态,不因上游曾批准就跳过检查
[!warning] Tool 接口设计是 agent 的可靠性表面 Anthropic 记录过 SWE-bench 工具路径案例:目录切换后相对路径容易误用,要求绝对路径消除了该案例中的路径歧义。该案例说明接口消歧有助于可靠调用,不能推导出所有任务的成功率只由工具文档决定。
人类工程师能否理解描述是初步检查,模型的实际调用成功率仍需单独评测。
错误处理分层:
| 错误类型 | 谁处理 | 怎么处理 |
|---|---|---|
| 参数校验失败 | Tool Layer | 返回结构化错误,让 LLM 自行修正 |
| 外部服务超时 | Tool Layer | 核实结果是否未知与是否可安全重试,再返回结构化状态 |
| 权限不足 | Tool Layer | 返回明确错误信息 |
| 工具不存在 | Runtime | 停止执行,报告给用户 |
关键原则:可恢复错误可以回填 context,让模型在运行时预算与权限内选择下一步。鉴权失败、数据损坏或副作用结果未知等情况应按 policy 停止、查询或升级;不能把所有恢复决策交给模型。
6. State & Persistence Layer
职责:管理 agent 运行所需的所有状态——短期会话、工作记忆、对话历史、长期记忆。
四层状态分离:
| 层级 | 内容 | 存储位置 | 生命周期 | 特征 |
|---|---|---|---|---|
| 运行状态 | 当前轮次、tool call、中间结果 | 内存或持久存储 | 一次 run 及其恢复期 | 是否 checkpoint 由恢复契约决定 |
| 工作记忆 | 模型当前使用的任务信息与摘要 | 内存或持久快照 | 任务/会话内 | 不代替权威执行状态 |
| 对话历史 | 多轮消息、agent 输出 | 按产品需要持久化 | 按保留策略 | 消息记录不代替业务数据库的事实 |
| 长期记忆 | 用户偏好、项目知识、实体记忆 | 数据库、文档、向量索引等 | 跨会话,受 TTL/删除策略控制 | 经作用域与相关性筛选后注入上下文 |
显式上下文设计:区分 run 范围的共享依赖与工具可访问的有限上下文。旧文中的 runtimeContext / toolsContext 可作为应用自定义命名,不应当作所有 Vercel AI SDK 版本都提供的标准 API;typed 字段本身也不形成权限隔离。
LangGraph 模式:StateGraph 的节点读取状态并返回更新,由 reducer 决定合并。配置 checkpointer 后,在 super-step 边界持久化状态,并通过 thread_id 关联恢复;这不是任意代码行或每次节点内部操作都可恢复。内存 saver 不能保证进程重启后恢复,外部副作用仍需去重与恢复设计。见 LangGraph Checkpointers。
[!tip] 状态管理核心原则
- 持久化消息历史用于恢复对话;关键执行与业务事实各有权威状态,不能从聊天摘要推断
- 长期记忆可由工具或运行时检索后选择性注入;不要把所有记忆塞进 prompt
- Runtime/上下文构建器管理窗口预算,状态层保存必要来源与快照;裁剪时保留工具调用与结果的对应关系
- 用明确类型和受限作用域减少隐式耦合,必要的访问边界仍由运行时执行
数据流:一次请求的完整路径
用户发送消息
│
▼
API Gateway ── 认证、限流、schema 校验、注入 request ID
│
▼
Orchestrator ── 判断路由(哪个 agent)
│
▼
Runtime ── 加载对话历史 + 检索长期记忆 → 组装 context
│
├──▶ LLM Provider ── 调用模型,流式返回
│ │
│ ▼
│ 模型输出:text / tool_call / handoff
│ │
│ ├── text → 输出策略校验 → 交付获准公开的内容
│ │
│ ├── tool_call → 执行前检查 approval → Tool Layer 执行
│ │ │
│ │ ▼
│ │ 结果(成功/错误)回填到 context
│ │ │
│ │ └── 回到 LLM Provider(下一轮)
│ │
│ └── handoff → 交给目标 agent(Orchestrator 层处理)
│
▼
State Layer ── 持久化必要运行状态、消息与业务结果
│
▼
确认终态(执行中可先交付获准的进度/内容事件)
│
▼
Observability ── 异步发送 trace(LLM 调用、tool 调用、耗时、token、成本)
关键观察:
- LLM 和 Tool 的调用是交替进行的,不是一次性的——runtime 必须支持循环
- 流式内容经过权限与输出策略后交付;需要完整验证的内容必须等待验证,不直接透传 raw proposal
- 可恢复工具错误可回填 context;runtime 决定恢复范围、预算与终态
- observability trace 可异步导出,但有采集、序列化和队列开销;关键业务审计记录不能只依赖可能丢失的后台导出
错误恢复与容错
Agent 系统的错误比普通 web 应用复杂得多,因为涉及 LLM 的不确定性。
分层容错策略
LLM 层:
- 模型超时 → 重试(指数退避),超过阈值后降级到备用模型(model fallback chain)
- 模型输出格式异常 → 重试一次(在 prompt 中强调格式要求),仍失败则报错
- 上下文溢出 → 触发上下文裁剪,重试
- 降级:只交付在已批准范围内的有限结果;完整分析失败不自动意味着摘要仍有效
Tool 层:
- 工具超时 → 区分确定失败与结果未知;在状态查询、幂等和预算约束内恢复
- 工具返回异常 → 结构化错误信息回填 context,模型可以理解并调整
- 工具不可用 → 注册时标记
degraded,runtime 跳过不可用工具 - 同时设置工具调用超时与整个 run 的总时限,重试和 fallback 也消耗总预算
Runtime 层:
- 超过最大轮次 → 记录明确停止原因;只返回可交付的部分结果,并说明任务未完成
- token 预算耗尽 → 强制停止(比轮次限制更精确,因为某些轮次 token 很少)
- 审批被拒绝 → 记录拒绝原因,让模型调整策略
Session 层:
- 内存历史丢失 → 从持久记录重建;可恢复范围取决于提交频率与 checkpoint,不能保证只丢最后一轮
- 长期记忆不可用 → 根据任务契约决定降级或停止;必要用户事实或权限信息不能用猜测补齐
死循环防护
一种常见的 agent 失效模式是无进展循环:模型反复调用同一个工具、反复得到同样的错误、反复重试。
三层防护:
1. 硬上限:max_turns(runtime 层,不依赖模型自觉)
2. 工具去重:连续 N 次相同 tool + 相同参数 → 强制中断
3. Stagnation 检测:如果 agent 在 K 轮后的输出与 K-2 轮无实质差异 → 终止
[!tip] Stagnation 检测(Evaluator 模式) 用一个轻量 evaluator 比较 agent 近 N 轮的输出。可以用文本相似度或 token 重叠率寻找疑似无进展的步骤。它只能作为启发式信号:重复文本可能对应有效轮询,新措辞也可能毫无进展;应结合工具状态、任务里程碑和预算判断。
OpenAI Agents SDK 的 Guardrail 模式:输入检查可采用 blocking 或 parallel 执行;并行检查降低等待,但主流程可能在结果返回前已开始工作。必须先验证的操作需使用阻塞检查或工具执行边界的授权,不能靠事后中断撤销已发生的副作用。见 Guardrails and human review。
可观测性
Trace 适合关联调用路径,Logs 提供细节,Metrics 用于趋势、SLO 和告警;三者互补。
Trace 模型(Langfuse 参考架构)
Session(多轮对话)
└── Trace(一次完整请求)
├── Observation: LLM 调用(prompt, completion, model, tokens, latency, cost)
├── Observation: Tool 执行(name, args, result, duration, success/fail)
├── Observation: 路由决策(为什么选这个 tool/agent)
└── Observation: 状态快照(每个决策点的 context)
关键设计:
- tracing 可以在后台批量导出;需控制采样、队列、背压和关闭时 flush,不能承诺零开销
- 按调用类型记录必要的输入/输出引用、耗时与成本;敏感 payload 需要脱敏、采样或受控保存
- session 维度聚合,可以回溯整个多轮对话的决策链
值得追踪的 5 类信息
- 每次 LLM 调用(prompt, completion, model, tokens, latency)
- 每次 tool 调用(name, arguments, result, duration, success/failure)
- 路由决策(为什么选这个 tool/agent)
- 决策相关状态的版本、引用与必要快照;敏感内容按访问和保留策略处理,不无限复制完整状态
- 成本累积(per-trace cost tracking)
可观测性 → 评估闭环
最好的系统把 tracing 和评估连起来:
- LLM-as-a-Judge:自动评估 agent 输出质量
- Prompt 管理:prompt 变更触发 eval suite
- 成本告警:异常成本自动通知
- A/B 测试:对比不同 prompt / model 的效果
工具选型:可比较 Langfuse、LangSmith 与已有的 OpenTelemetry/APM 链路。LLM 专用工具便于查看 token、Prompt 和评测结果;通用平台也可通过语义属性与集成扩展,是否足够取决于已有栈和需求。
Debug 工作流
1. 从 trace 复现失败
2. 检查每个决策点的状态
3. 检查工具名称、参数、结果与实际调用失败证据,判断接口是否存在歧义
4. 检查 prompt 是否充分
5. 添加测试用例到 eval suite
从 Prototype 到 Production 的架构演进
阶段一:单文件 Prototype(Raw API)
main.py
├── prompt + LLM API 调用
├── 几个 tool 函数
└── print 输出
目标:验证 agent 能不能完成核心任务。
关键原则:从能验证任务的最小方案开始;已有熟悉的框架也可复用,但应理解实际执行路径。
退出条件:代表性成功任务、关键失败场景和成本/延迟达到预先定义的验收条件;通过几个示例仅是初步证据。
阶段二:加入 Runtime 和 API(Managed Loop)
app/
├── api/ # FastAPI / Gin 路由
├── runtime/ # 执行循环 + 停止条件
├── tools/ # 工具注册 + 执行
└── llm/ # 模型调用抽象
目标:能通过 HTTP 调用,有基本的错误处理和流式输出。
新增:LLM Provider 抽象、Tool 注册机制、Runtime 执行循环。
阶段三:加入状态管理和可观测性
app/
├── api/
├── runtime/
├── tools/
├── llm/
├── state/ # 会话管理 + 工作记忆 checkpoint + 对话历史持久化
├── memory/ # 长期记忆 + 向量检索(通过 tool 接入)
└── observability/ # tracing + 成本追踪 + eval
目标:支持多轮对话,重启后能恢复上下文,出问题能回溯。
[!tip] 状态管理和可观测性应该同阶段引入 状态持久化支持恢复,可观测记录帮助解释上下文、工具与成本变化。二者通过 run/session identity 关联;tracing 可以独立关联多次请求,但不能代替业务执行状态。
阶段四:生产化
这个阶段解决的是真实痛点,不是架构完整性:
| 痛点 | 解法 |
|---|---|
| Tool 接口失败率高 | 根据失败样本改进参数、路径基准、错误返回与文档,再评测效果 |
| 成本失控 | per-trace 成本追踪、请求级 token 预算、easy task 降级到便宜模型 |
| 延迟太高 | 测量瓶颈;按需 streaming、并行独立调用、缓存,并设置预算 |
| 输出不确定 | 定义行为契约、schema 校验与回归评估;temperature=0 不保证确定性,JSON mode 不等于严格 schema 约束 |
| 状态丢失 | 外部状态存储(Redis/Postgres)、checkpoint 工作记忆 |
阶段五:多 Agent 和扩展
app/
├── gateway/ # API Gateway
├── orchestrator/ # 按任务需要选择多 agent 编排
├── agents/ # 多个独立 agent
│ ├── research/
│ ├── coding/
│ └── review/
├── shared/ # 共享工具、LLM Provider、状态层
└── ...
关键:只有出现实际的路由、协调或并行分工需求时才增加 Orchestrator;演进阶段不是所有项目都必须按顺序经过的清单。
常见反模式
| 反模式 | 问题 | 正确做法 |
|---|---|---|
| 一锅炖 | runtime、工具、API 逻辑全在一个文件 | 分层,每层只做一件事 |
| 隐式状态依赖 | 工具函数内部读 agent 的全局变量 | 显式传递依赖、限定作用域并约束副作用 |
| 硬编码模型 | 换模型要改所有调用处 | LLM Provider 抽象,运行时可切换 |
| 无限循环靠模型自觉 | 模型说”我停止了”就停 | runtime 设硬上限 + stagnation 检测 |
| 对话历史存在内存里 | 重启丢数据,多实例不共享 | 持久化到数据库,内存只是缓存 |
| 日志只打 print | 上线后无法排查问题 | 结构化 tracing + request ID 贯穿 |
| 过早引入框架 | LangGraph/CrewAI 的概念比你的需求复杂 | 先用 Raw API 跑通,复杂度到了再引入框架 |
| 单大 dict 传状态 | 任何 tool 都能读写任何状态,隐式耦合 | typed + scoped 状态容器(runtimeContext / toolsContext) |
| 多 agent 解决单 agent 问题 | 增加复杂度但效果没提升 | 在相同任务与预算下比较方案,仅保留有证据的复杂度收益 |
边界与易混淆点
- 这不是微服务架构:六层是逻辑分层,不是物理分层。单体部署时它们是模块,分布式部署时它们才变成服务
- Orchestrator 不是必须的:单 agent 项目只需要 Runtime + Tool Layer + LLM Provider,不要为了架构完整性硬加编排层
- Runtime ≠ 框架:LangGraph、CrewAI 是框架,它们帮你实现 runtime。但你需要先理解 runtime 该做什么,才能判断框架是否合适
- 记忆 ≠ 上下文:上下文是本次模型调用可见的信息;记忆按需提供其中一部分,既有工作记忆也有跨会话记忆,详见 agent-memory-types-and-policies
- 工作记忆 ≠ 对话历史:对话历史是消息记录,工作记忆是当前推理工作集;可恢复的执行进度需显式保存为 run state
- Guardrail ≠ Stopping condition:guardrail 验证规则,stopping condition 定义终止条件;一次 guardrail 触发可以成为停止原因,二者并非必须独立或并行运行