系统设计:Design an Agent Orchestration Platform
“给 LLM 接几个工具”还不是 Agent 平台。真正的系统问题出现在模型开始循环之后:模型决定下一步,工具产生结果,结果又进入下一轮模型调用,直到任务完成、预算耗尽、等待人工批准,或者被用户取消。
这个循环每多走一步,都会增加 token 成本、网络延迟和一次失败机会。更麻烦的是,工具可能真的改变外部世界:发邮件、退款、删文件、创建云资源。模型输出错一句话只是答案不好;工具被重复执行一次,可能就是一笔真实损失。
因此这道题的核心不是 prompt engineering,而是:怎样把不确定的模型决策包在一个确定、可追踪、可恢复的执行系统里。
配套实验:打开 Agent Orchestration Lab。先保持
max steps = 1,再逐步增加工具数和上下文,不要直接跳到“全自动 Agent”。
一个退款为什么会执行两次
用户说:“给订单 991 退款。”
模型正确地产生了工具调用:
{
"tool": "create_refund",
"arguments": { "orderId": "991", "amount": 49.00 }
}
Orchestrator 调用支付服务。支付服务已经完成退款,但响应在网络中丢失。Orchestrator 只看到 timeout,于是重试同一段 JSON。如果支付接口没有幂等键,用户可能收到两次退款。
这个故障和模型“聪不聪明”没有关系。它是一个标准的分布式系统问题:调用方不知道远端究竟“没执行”,还是“执行成功但回包丢了”。
所以一次工具调用至少要有稳定的 tool_call_id:
POST /refunds
Idempotency-Key: run-7-step-3-call-1
{"orderId":"991","amount":49.00}
同一个 key 重试多少次,支付服务都必须返回同一个业务结果。仅仅在 Orchestrator 的日志里去重不够,因为第一次请求可能已经越过了 Orchestrator 的边界。
这是整篇设计的第一个抓手:Agent 的每一步都可能重试,外部副作用必须能识别“这是同一次意图”。
先把执行模型讲清楚
Run
一次完整用户任务。例如“调查订单并在符合条件时退款”。Run 可能持续几秒,也可能等待人工审批几小时。
Step
Run 中的一次决策。通常包含:组装 context、调用模型、验证模型输出,以及零个或多个 tool call。
Tool call
对外部能力的一次结构化调用。工具不是一段模型文本,而是一份需要 schema 校验、权限检查、timeout、retry 和审计的请求。
Checkpoint
Run 已经可靠完成到哪个位置。Worker 崩溃后,新 worker 应从最近 checkpoint 继续,而不是靠内存猜测。
Durable waiting
当 Run 等待人工审批或外部事件时,它应持久化为 waiting 并释放 worker。不能让一个线程或容器阻塞几个小时。
题目边界:先做受控执行,不做“无限自治”
平台支持:
- 创建、查询、订阅和取消一个 Run;
- 调用经过注册的工具;
- 在敏感步骤暂停,等待人工批准或拒绝;
- Worker 故障后从 checkpoint 恢复;
- 记录每步模型版本、输入摘要、工具参数、结果与成本。
第一版只开放只读工具,例如查订单、查库存、搜索内部文档。写工具要等到幂等、审批和审计链路完成后再开放。
非功能约束比“能调用多少工具”更重要:
- 每个 Run 有总 deadline、最大 step、token 和费用预算;
- 租户凭证、工具结果和长期 memory 必须隔离;
- 模型输出永远按不可信输入处理;
- 用户取消后不得再开始新的副作用;
- 每一个外部动作都能追溯到用户、policy、模型版本和具体参数。
第一版:只允许一次模型决策和一个只读工具
最小版本不要写 while True。先固定执行链:
user input
-> LLM chooses one read-only tool
-> validate JSON schema
-> authorize tool + resource
-> execute tool
-> LLM writes final answer
伪代码可以非常朴素:
def answer_with_one_tool(user, input_text):
action = llm.choose_tool(input_text, allowed_tools=user.allowed_tools)
validate_against_schema(action)
authorize(user, action.tool, action.arguments)
result = tool_runner.execute(
tool=action.tool,
arguments=action.arguments,
timeout_seconds=5,
)
return llm.write_answer(input_text, action, result)
这一版先解决三件基础问题:
- 模型返回未知字段、错误类型或不存在的工具时,系统明确拒绝;
- 用户能查“自己的订单”,不等于他能通过参数查别人的订单;
- 工具返回一大段不可信文本时,它只是数据,不能偷偷改变系统指令。
完整记录原始模型输出很有排障价值,但敏感参数和工具结果需要按字段脱敏。Observability 不能成为另一条数据泄露路径。
第二版:把循环写成显式状态机
确认单步语义正确后,再允许最多 5 个 step。不要把 Run 只存在 worker 内存里,而要把状态转换写入 durable store。
QUEUED
-> RUNNING_MODEL
-> RUNNING_TOOL
-> RUNNING_MODEL
-> COMPLETED
RUNNING_TOOL -> WAITING_APPROVAL
WAITING_APPROVAL -> RUNNING_TOOL | REJECTED
any non-terminal state -> CANCELLING -> CANCELLED
any state -> FAILED | TIMED_OUT
主循环更接近:
while run.has_budget() and not run.deadline_expired():
step = store.create_step(run.id)
action = call_model(build_context(run, step))
store.save_model_decision(step.id, action)
if action.type == "finish":
store.complete_run(run.id, action.answer)
break
tool_call = validate_authorize_and_persist(action)
if policy.requires_approval(tool_call):
store.wait_for_approval(run.id, tool_call.id)
return
result = execute_idempotently(tool_call)
store.save_tool_result_and_checkpoint(step.id, result)
顺序很重要。执行工具前先持久化稳定的 tool_call_id;结果写入并 checkpoint 后,才能开始下一次模型决策。否则 worker 在副作用之后、checkpoint 之前崩溃,就无法区分是否需要重试。
API:Run 是异步资源,不是一条永不结束的 HTTP
创建 Run:
POST /v1/agent-runs
Idempotency-Key: support-ticket-88
{
"agentVersion": "support-agent@17",
"input": "检查订单 991,如果符合政策就退款",
"maxSteps": 5,
"deadlineSeconds": 60,
"maxCostUsd": 0.50
}
202 Accepted
{"runId":"run-7","state":"queued"}
观察、订阅和取消:
GET /v1/agent-runs/run-7
GET /v1/agent-runs/run-7/events
DELETE /v1/agent-runs/run-7
审批不是一个同步弹窗,而是一项 durable command:
POST /v1/agent-runs/run-7/approvals/approval-2
{"decision":"approve","comment":"符合七天退款政策"}
批准请求本身也要幂等。审批时重新验证 Run、tool 参数和 policy version,不能批准了一个后来已被修改的动作。
数据模型:把“模型说了什么”和“系统做了什么”分开
Run(
run_id, tenant_id, agent_version, state,
step_count, token_used, cost_used,
deadline_at, created_by, created_at, updated_at
)
Step(
run_id, sequence, state,
model_version, context_ref, model_output_ref,
started_at, completed_at
)
ToolCall(
tool_call_id, run_id, step_sequence,
tool_name, tool_version, arguments_hash,
arguments_ref, state, attempt_count, result_ref
)
Approval(
approval_id, tool_call_id, policy_version,
state, requested_at, decided_by, decided_at
)
RunEvent(
run_id, sequence, event_type, payload_ref, created_at
)
大的 prompt、网页正文和工具结果放 object storage,数据库保存引用、hash 和必要摘要。RunEvent.sequence 给客户端一个稳定的进度顺序,断线重连时可以从 last_seen_sequence 继续。
长期 memory 不要混进 Run 表。它至少需要独立记录:
MemoryEntry(
memory_id, tenant_id, subject_id,
content, provenance, confidence,
visibility, expires_at, created_by_run
)
没有 provenance 的“记忆”很危险。模型在一次对话里猜错了用户地址,若系统把它当事实永久保存,未来每个 Run 都会继承错误。
高层架构:Orchestrator 负责状态,不负责信任模型
flowchart LR U[Client] --> API[Run API] API --> E[(Run event log)] E --> O[Orchestrator workers] O --> L[LLM gateway] O --> P[Policy engine] P --> A[Approval service] O --> T[Tool router] T --> S[Sandbox] T --> B[Business services] O --> C[(Checkpoint store)] O --> X[Trace + cost pipeline]
职责边界如下:
- Run API 接受命令并返回异步 ID,不执行长任务。
- Event log / queue 解耦请求流量和 worker,并提供至少一次投递。
- Orchestrator 是 Run 状态机 owner,负责 lease、checkpoint、预算和恢复。
- LLM gateway 统一模型限额、版本、timeout 和 fallback。
- Policy engine 决定某个用户能否用某个工具、哪些参数需要审批。
- Tool router 只把经过验证的调用送到正确信任边界。
- Sandbox 执行代码、shell 或不可信文件;业务服务执行退款等强语义动作。
不要把所有工具都放进同一个“万能 sandbox”。读取内部 CRM 和运行用户上传代码需要完全不同的网络、凭证和资源隔离。
容量估算:一个用户请求会放大成多少工作
假设高峰每秒进入 1,000 个 Run,平均 8 个 step,每个 step 调一次模型:
1,000 runs/s × 8 steps = 8,000 model calls/s
若每步平均输入 20K tokens:
8,000 × 20,000 = 160M input tokens/s
这说明系统首先可能撞上模型 gateway 的 token capacity,而不是 Run API 的 QPS。
若每个 Run 平均执行 3 个工具:
1,000 × 3 = 3,000 tool calls/s
这些调用会落到不同下游,不能用一个总并发限制。搜索工具可能允许几千并发,支付写接口可能只允许几十。Tool router 应按 tenant × tool 设置独立 quota 和 circuit breaker。
还要估算等待状态。若每天有 100 万个 Run 进入人工审批,平均等待 4 小时,系统会长期持有约 16.7 万个 waiting Run。它们不能占 worker,却必须能被查询、过期和恢复。
延迟预算:多步串行会直接相加
假设一次模型调用 2 秒,一次工具调用 1 秒。8 个完全串行的 step 已经是:
8 × (2s + 1s) = 24s
因此平台应从用户体验上区分两类任务:
- 交互任务尽量在几秒内完成,限制 step,并流式展示可靠的进度事件;
- 长任务立即返回 Run ID,后台执行,完成后通知用户。
没有数据依赖的只读工具可以并行,例如同时查订单和查物流。但两个写操作是否并行,是业务语义问题,不是性能开关。并发执行“退款”和“取消发货”可能产生难以补偿的中间状态。
每个下游调用都要拿到“剩余 deadline”,而不是重新获得完整 timeout。一个 Run 已经花掉 55 秒时,最后一步不能再向工具申请 30 秒。
Context 不能无限增长
最简单的实现会把所有历史 step、工具结果和 schema 每轮都塞回 prompt。随着 Run 变长,成本和 latency 会二次恶化:后面的每一步都重复发送前面的全部内容。
更可控的 context 由四层组成:
- 固定的 agent policy 和当前任务;
- 最近几步的原始事件;
- 较早历史的有来源摘要;
- 按当前问题检索出来的长期 memory。
摘要不是事实数据库。它必须保留关键 ID、未完成约束和来源引用;对金额、权限、地址等高风险字段,应重新从系统 of record 查询,而不是相信模型写的 summary。
工具数量变多时,也不要把上百份 JSON Schema 永久塞进 prompt。先根据任务 route 到一个小工具集合,再让模型选择。这样同时降低 token、误选概率和权限暴露面。
正确性、恢复与取消
Worker crash
Worker 对 Run 只持短 lease。Lease 过期后,新 worker 从最后 checkpoint 恢复。因为队列是至少一次投递,同一个 Run 可能被取到多次;状态转换要用 version compare-and-swap,避免两个 worker 同时推进。
Tool timeout
只对明确可重试的错误重试,并复用同一个 tool_call_id。如果工具不支持幂等,就必须查询结果、人工确认或执行补偿,不能盲目重试写请求。
取消
取消先把 Run 原子地改为 CANCELLING,从这一刻起禁止创建新 tool call。对已经发出的调用发送 cancel;若远端无法取消,就等待结果并决定是否补偿。最后才进入 CANCELLED。
审批过期
审批请求要有 expiry。恢复执行前重新确认资源和 policy;一周前批准的价格或库存操作未必仍有效。
循环跑偏
除了 max_steps,还应检测重复 tool + arguments、连续无进展结果和成本增长。终止原因要对用户可见,不能统一显示“Agent failed”。
安全边界:模型输出默认不可信
最少需要以下防线:
- JSON Schema 只保证形状,不保证用户有权访问参数里的资源;
- 工具采用最小权限的短期凭证,不把主服务密钥放进 prompt;
- 网页、邮件和文件内容视为不可信数据,不能覆盖 system policy;
- 代码执行使用进程、文件系统、网络和资源配额隔离的 sandbox;
- 高风险动作在 policy 层强制审批,不能让模型自己决定绕过;
- Trace 与日志对 secret、PII 和工具结果做字段级脱敏。
“让模型判断这条指令是否安全”可以是辅助信号,但不能替代确定性的授权检查。
关键指标和故障告警
需要同时观察产品、模型和执行三层:
- Run completion、cancel、timeout、approval 和 human-escalation rate;
- 每个 Run 的 step、input/output token、费用和总耗时分布;
- 每个工具的成功率、p99、retry、幂等命中和 circuit-open 次数;
- waiting Run 数、最长等待时间、lease steal 和恢复次数;
- context 长度、compaction 次数、memory retrieval 命中;
- policy deny、schema reject、sandbox violation 和越权尝试。
只看最终“任务成功率”会掩盖一个成本失控的 Agent。一个任务最后完成了,但绕了 40 步、花了 10 美元,也不是健康系统。
关键取舍:自治不是越多越好
更多 step 可能提高复杂任务成功率,也线性扩大成本、延迟和跑偏空间。
并行工具 能缩短只读查询时间,但会让失败合并、顺序和副作用补偿更复杂。
自动长期记忆 让体验连续,也会把模型错误持久化。高风险事实应由用户确认或来自权威系统。
模型 fallback 提高可用性,却可能改变工具选择、输出格式和安全表现。切换模型前要验证兼容性,不能只看接口相同。
精细 checkpoint 减少故障重做,却增加写放大和状态管理。通常在模型决策后、工具调用前、工具结果后设置语义 checkpoint,比每个 token 都落盘更合理。
用 Lab 观察系统为什么需要这些组件
实验一:增加 max steps
保持并发不变,从 1 增加到 10。观察模型调用和总 latency 如何近似线性放大。问自己:哪些 step 真的产生了新信息?
实验二:增加工具数和 context
观察每次模型调用的 token 和路由压力。然后假设先用一个轻量 router 选出 5 个工具,再比较成本。
实验三:增加并发 session
注意瓶颈会在 LLM quota、某个 hot tool 和 waiting Run 之间移动。Run API 能接住流量,不代表下游副作用能接住。
面试里怎么自然地讲
开场先用退款例子锁定问题:
I would not model an agent as a loop living inside one worker. I would model each run as a durable, budgeted state machine, because every model step can fail and every tool call may create an external side effect.
然后按演化顺序画:
one read-only tool
-> bounded multi-step loop
-> durable checkpoints
-> idempotent side effects
-> approval + cancellation
-> context compaction and multi-tenant isolation
主链路讲完后,可以把 deep dive 交给面试官:
The main execution path is complete. I can go deeper into exactly-once business effects, durable recovery, sandbox security, or context and memory management.
这比一句“LLM 调 MCP tools”更像系统设计,因为你解释了系统在失败和重试下仍然如何守住业务语义。