如一 Agent 架构解读(一):数字分身的执行模型——Run、Turn 与四个时间尺度

22 阅读10分钟

「如一」是我们做的一个面向 C 端的云端数字分身:它在云端 7×24 运行,有自己的浏览器、文件系统和记忆,能替你持续完成任务。这个系列逐层解读如一的真实架构——不是 Demo 级玩具,而是一个多租户生产系统。开篇先讲最核心的执行模型:一个 Agent 到底"活"在什么结构上。

为什么大多数 Agent 只是 Demo

先看几个 Agent 项目的经典死法:

  • 进程崩一次,会话上下文全丢,用户只能从头再来;
  • 模型追问"你确认吗?",然后整个线程挂在那里等用户回复,一等就是三天;
  • 模型被限流,重试逻辑无限循环,账单爆炸;
  • 上下文越来越长,直到某一天 prompt 超出窗口,整个会话永久报废。

这些都不是模型能力问题——换个更强的模型,一个都解决不了。它们是架构问题:把 Agent 当成了一次函数调用,而它实际上是一个持续运行系统。

如一从第一天就是奔着"长期在线的数字分身"去设计的,所以这些问题必须在架构层回答。这篇讲清楚我们的答案:四个时间尺度、一条决策与执行的分工线,以及几个反直觉但被生产验证过的设计决策。

先给定义:如一眼中的 Agent

Agent 不是"带工具的聊天模型",也不是一条从 Prompt 到 Answer 的请求链。如一的定义是:

一个由消息驱动、以持久事实为连续性基础、由模型进行开放决策、由运行时保证确定性执行,并能持续对外部世界产生可验证结果的智能运行系统。

五个要素,缺一不可:

  • 消息驱动:外部世界以统一消息进入。Agent 不直接依赖具体渠道——WebSocket、IM 协议、摄像头驱动,都是接入层的事。
  • 事实连续:会话历史、工具结果、交付状态进入权威存储。进程内对象不是长期记忆。
  • 开放决策:目标理解、路线选择、内容判断交给模型。
  • 确定执行:状态迁移、权限、事务、幂等、并发、恢复由程序保证。
  • 真实结果:输出不只是文本,还可以是文件、应用、卡片,以及对外部系统的真实操作。

三个平面:Input / Runtime / Output

flowchart TB
 WORLD[`外部世界<br/>用户 / 设备 / 系统事件 / 定时任务`]

 subgraph INPUT[`Input Plane`]
 IN[`身份归一 / 鉴权 / 多模态接入<br/>幂等 / 排队 / 信箱`]
 end

 subgraph RUNTIME[`Agent Runtime Plane`]
 RUN[`会话调度 - Start Run - 恢复事实<br/>- AgentLoop Turn x N- End Run`]
 end

 subgraph OUTPUT[`Output Plane`]
 MSG[`Message<br/>沟通与反馈`]
 ART[`Artifact<br/>可复用成果`]
 CMD[`Command<br/>改变外部世界`]
 end

 WORLD --> INPUT
 INPUT -- `RuntimeCommand` --> RUNTIME
 RUNTIME -- `RuntimeEvent` --> OUTPUT
 MSG --> WORLD
 ART --> WORLD
 CMD --> WORLD

三个平面各有一条容易被忽视的设计原则。

Input Plane 的核心是"信箱",它不是 UI 比喻,是并发模型。 外部消息可以随时到达;同一会话的主执行必须串行消费;进程故障后未完成的输入必须能被重新认领。Runtime 不应该知道手机号、Cookie、WebSocket 或某个 IM 的报文结构——它只处理通道中立的命令和消息。

Output Plane 的核心是"交付不等于回答"。 如一的输出至少有三类:Message(解释、确认、进度、最终文本)、Artifact(文件、报告、图片、音视频、可交互应用)、Command(对文件、浏览器、外部系统的真实操作)。三者都可以是中间结果或最终交付。是否展示、何时展示、用什么媒介展示,取决于用户目标,而不是"某个工具刚刚返回了文件"。

Runtime Plane 是决策与执行内核,也是本文剩下部分要拆的东西。

把三个平面展开,如一的完整架构长这样(实线是命令、执行与交付的主路径,虚线是发现、读取、持久化等事实关系):

flowchart TB
 WORLD[`外部世界<br/>用户 / 设备 / 系统 / 时间`]

 subgraph CHANNELS[`交互与接入`]
 direction LR
 WEB[`Web / App / IM`]
 MEDIA[`语音 / 图像 / 视频`]
 SENSOR[`传感器 / 系统事件`]
 TIMER[`定时任务`]
 end

 subgraph GATEWAY[`Input / Output Plane / Gateway`]
 direction LR
 INGRESS[`身份与协议边界<br/>鉴权 / 归一 / 附件接入`]
 MAILBOX[`可靠消息信箱<br/>幂等 / 排队 / 同会话串行`]
 PROJECTION[`消息投影与连接<br/>历史恢复 / WebSocket`]
 end

 subgraph RUNTIME[`Agent Runtime Plane`]
 direction TB
 SCHEDULER[`会话调度<br/>认领 / 租约 / 追加消息`]

 subgraph RUN[`Agent Run / 有限执行边界`]
 direction TB
 START[`Start Run<br/>固定身份 / Profile / Deadline`]
 RESTORE[`Restore Facts<br/>Transcript / Compaction / Memory`]

 subgraph LOOP[`AgentLoop`]
 direction TB
 GUARD[`Guard<br/>Run 边界守护`]
 BEGIN[`Begin Turn<br/>turnNo + 1`]
 OBSERVE[`Observe<br/>排干消息`]
 CONTEXT[`Build Context<br/>按当前决策投影`]
 ATTEMPT[`Model Attempt<br/>attemptNo + 1 / 模型开放决策`]
 ACT[`Act<br/>工具批 / 领域用例`]
 COMMIT[`Commit Assistant<br/>提交模型事实`]
 DECISION_ROUTE{`Route Decision`}
 TOOL_COMMIT[`Commit Tools<br/>提交观察事实`]
 FINISH_TURN[`Finish Turn<br/>唯一执行终态`]
 ROUTE{`Continue Run?`}

 GUARD --> BEGIN --> OBSERVE --> CONTEXT --> ATTEMPT
 ATTEMPT -- `failed / retry<br/>同一 Turn` --> OBSERVE
 ATTEMPT -- `valid response` --> COMMIT
 COMMIT --> DECISION_ROUTE
 DECISION_ROUTE -- `recover / continuation<br/>同一 Turn` --> OBSERVE
 DECISION_ROUTE -- `tools` --> ACT --> TOOL_COMMIT --> FINISH_TURN
 DECISION_ROUTE -- `stop / abort` --> FINISH_TURN
 FINISH_TURN --> ROUTE
 ROUTE -- `next Turn` --> GUARD
 end

 FINISH[`End Run<br/>Completed / Failed / Aborted`]
 START --> RESTORE --> GUARD
 ROUTE -- `end Run` --> FINISH
 end

 EVENTS[`RuntimeEvent<br/>过程反馈 / 权威交付 / 终态`]
 SCHEDULER --> START
 FINISH --> EVENTS
 COMMIT -. `模型增量事件` .-> EVENTS
 TOOL_COMMIT -. `工具事件` .-> EVENTS
 end

 subgraph CAPABILITIES[`Capability & Execution Plane`]
 direction LR
 SKILL[`Skill<br/>领域选路`]
 TOOL[`Tool / Harness<br/>确定性能力`]
 USECASE[`Application Use Case<br/>事务与一致性`]
 SANDBOX[`Sandbox / Browser<br/>隔离 I/O`]
 EXTERNAL[`外部服务`]
 end

 subgraph FACTS[`Authoritative Fact Plane`]
 direction LR
 MYSQL[`MySQL<br/>Transcript / Run / Memory / Task`]
 REDIS[`Redis<br/>RunSlots / Queue / Stream`]
 OBJECTS[`OSS / Workspace<br/>附件 / Artifact`]
 end

 subgraph DELIVERY[`Delivery`]
 direction LR
 MESSAGE[`Message<br/>沟通与反馈`]
 ARTIFACT[`Artifact<br/>可复用成果`]
 COMMAND[`Command<br/>改变外部世界`]
 end

 WORLD --> CHANNELS
 WEB --> INGRESS
 MEDIA --> INGRESS
 SENSOR --> INGRESS
 TIMER --> MAILBOX
 INGRESS --> MAILBOX
 MAILBOX -- `RuntimeCommand` --> SCHEDULER

 ATTEMPT -. `发现并选择` .-> SKILL
 ACT --> TOOL --> USECASE
 TOOL --> SANDBOX
 USECASE --> EXTERNAL
 SANDBOX --> EXTERNAL
 TOOL -- `观察结果` --> TOOL_COMMIT
 USECASE -- `业务结果` --> TOOL_COMMIT

 MAILBOX -.-> REDIS
 RESTORE -. `读取` .-> MYSQL
 COMMIT -. `追加事实` .-> MYSQL
 TOOL_COMMIT -. `追加事实` .-> MYSQL
 SCHEDULER -.-> REDIS
 EVENTS -- `Stream / PubSub` --> REDIS
 SANDBOX -.-> OBJECTS

 REDIS --> PROJECTION
 EVENTS --> PROJECTION
 PROJECTION --> MESSAGE
 PROJECTION --> ARTIFACT
 ACT --> COMMAND
 MESSAGE --> WORLD
 ARTIFACT --> WORLD
 COMMAND --> WORLD

这张图的密度很高,值得放大看的部分后面都会逐一讲到。本文聚焦 Runtime Plane 内部,也就是 Run 和 Turn 那一块。

四个时间尺度:Session / Run / Turn / Model Attempt

Agent 系统最容易混淆的概念就是这四个,它们不是同一层东西:

尺度定义结束条件连续性靠什么
Session用户与 Agent 的长期会话空间产品生命周期决定transcript、会话投影
Run一次被消息触发、连续推进到终态的执行completed / failed / aborted持久事实 + runId
TurnRun 内一次"观察—决策—行动—提交"的节拍继续下一 Turn 或终结 Run本轮提交的 assistant/tool 事实
Model AttemptTurn 内真正发起的一次模型调用合法返回、失败或取消逐次调用事实记录

这个区分带来几个直接结论:

  • 一个 Session 包含多个 Run。用户回答 Agent 的提问,是启动一个新 Run,不是恢复一条阻塞的线程。
  • 一个 Run 通常包含多个 Turn——模型调用工具后需要看到结果再继续判断。
  • 一个 Turn 包含一到多个 Model Attempt。恢复重试、协议重建、超长续写,只增加 Attempt,不增加 Turn。
  • Model Attempt 是推理事实和计费事实,不是 Agent 任务的生命周期。

坦白说,最后一层区分是如一的最近一次线上重构里长出来的。早期实现里,模型被限流重试一次,前端的"第 N 步"就 +1,用户眼睁睁看着分身"走了 20 步"其实一件事还没做完;运行统计里 turn 数和模型调用数混在一起,排障时根本对不上。重构之后:Turn 只统计认知—行动节拍,Attempt 只统计真正进入模型的调用,恢复绝不虚增 Turn。

Run 内部的结构展开是这样:

flowchart TB
 START[`Start Run<br/>固定身份 / Profile / Deadline`] --> RESTORE[`Restore Facts<br/>从持久存储恢复历史`]
 RESTORE --> GUARD[`Guard<br/>abort / deadline / 上限守护`]
 GUARD --> BEGIN[`Begin Turn`]
 BEGIN --> OBSERVE[`Observe<br/>排干排队消息`]
 OBSERVE --> CONTEXT[`Build Context<br/>为当前决策构造投影`]
 CONTEXT --> ATTEMPT[`Model Attempt<br/>模型开放决策`]

 ATTEMPT -- `失败 / 限流 / 截断<br/>恢复后仍在同一 Turn` --> OBSERVE
 ATTEMPT -- `合法响应` --> COMMIT[`Commit<br/>提交模型事实`]
 COMMIT --> ROUTE{`Route`}
 ROUTE -- `执行工具` --> ACT[`Act<br/>工具批执行`]
 ACT --> TOOLCOMMIT[`Commit Tools<br/>提交观察事实`]
 TOOLCOMMIT --> FINISH[`Finish Turn<br/>唯一执行终态`]
 ROUTE -- `停止 / 终止` --> FINISH
 FINISH --> NEXT{`继续?`}
 NEXT -- `下一 Turn` --> GUARD
 NEXT -- `结束` --> END[`End Run<br/>Completed / Failed / Aborted`]

注意这张图刻意把模型放在 Turn 的"Decide"位置,而不是系统中心。系统的中心是从消息到事实、再从事实到行动的闭环,模型只是这个闭环里最擅长开放判断的那个零件。

一条分工线:决策自由与执行确定

Agent 系统设计的关键,不是尽可能多地把控制交给模型,而是把不同性质的决策放到正确的一侧。

模型负责开放决策:理解用户真正想完成什么;判断事实是否充分;选择能力、顺序和探索路径;根据工具观察调整路线;判断内容、审美和交付媒介;决定继续行动、向用户询问还是结束。

平台负责确定机制:消息幂等与同会话串行;参数与协议校验;权限、隔离、资源和 deadline;事务、幂等与副作用边界;超时、取消、重试预算和崩溃恢复。

一句话概括:

模型拥有路线选择权,平台拥有事实裁决权和执行约束权。

很多 Agent 项目的问题正是这条线画错了:让模型用 prompt 约束去"保证"幂等、用自然语言去"维护"事务、用系统提示词去"防止"路径越权。这些都是机械可验证的机制,交给一个概率模型去守护,出事只是时间问题。反过来,把路线选择写死成固定工作流的,也不叫 Agent,叫 RPA。

五个反直觉的设计决策

以下每一条都和直觉相反,也都在如一的生产环境里真实运行着。

1. Ask = 工程上结束 Run

分身需要用户补充信息或授权时怎么办?直觉答案是"挂起线程等回复"。我们的答案是:提交问题,发出事件,然后正常结束当前 Run。用户回复作为新消息启动新 Run,从持久历史里重新规划。

当前 Run:提交问题 → 发出 QUESTION_ASKED → COMPLETED
用户回复:新 RuntimeCommand → 新 Run → 从 transcript 继续

系统里不存在 SUSPENDED 状态,没有挂起线程,没有等待句柄。分身可以 7×24 在线,靠的不是一个永不退出的 while(true),而是持久事实 + 新消息的再次驱动。

2. 恢复不是新 Turn

模型被限流、流式响应超时、prompt 超长、输出被截断——这些都在同一个 Turn 内恢复,而不是"进入下一轮"。每个已开启的 Turn 必须且只能闭合一次(继续 / 完成 / 中止 / 失败);每次真正的模型调用必须落入 completed / failed / cancelled 之一。

好处不止是统计口径干净:Turn 上限这种安全护栏不会被瞬态故障消耗掉,排障时"第 3 轮第 2 次尝试"也能精确定位到单次模型调用的耗时和用量。

3. 连续性来自提交点,不来自"模型想过什么"

分身能不能从崩溃中正确恢复,取决于哪些事实已经提交,而不是模型生成过什么。所以提交纪律是硬不变量:

  • 非法或残缺的工具调用不能提交,也不能执行;
  • assistant 的工具调用一旦提交,每个调用必须有且只有一个结果;
  • 用户取消导致未执行的工具,要补稳定的 cancelled 结果,防止历史形成残链;
  • 中断的模型输出,只保留可安全回放的正文和完整工具调用。

每个 Turn 都必须把"模型在下一次恢复时能合法理解的事实链"作为提交单位,而不是只追求实时输出看起来连续。

4. 历史 ≠ 上下文

Transcript 是已发生事实的权威记录;Context 是为了当前 Turn 的决策、从历史和其他信息中临时构造的投影。每个 Turn 可以组合:未被摘要覆盖的历史、压缩摘要、本轮刚到的用户消息、system prompt、记忆与技能提醒、当前附件。

两条推论:上下文压缩改变的是模型看到的决策界面,绝不篡改已经发生的历史;system prompt 这类"每轮计算的运行环境"不写进历史,因为它不是会话中发生的消息。

判断某段信息该不该进上下文,只需要问一个问题:

它会改变当前 Turn 的哪一个决策?

答不上来的,就不该常驻上下文。

5. 失败是追加事实,不是抹除历史

Run 异常终止时,除了向用户投影错误,如一还会向历史追加一条经过裁剪的 failure observation。下一次 Run 能知道"上一次任务没有完整完成",同时继续信任此前已经提交的消息、工具结果和交付物。

失败不是把历史回滚到"没发生过",而是向历史增加一个新的现实事实。

几条可靠性不变量

最后留几条我们认为比任何具体实现都更稳定的不变量,供对照你自己的系统:

  1. 同一会话最多一个活跃主 Run,追加消息可靠入队。
  2. 每个 Run 都能从持久存储重建,不依赖旧进程的任何对象。
  3. assistant 工具调用与工具结果必须合法配对。
  4. 已提交事实不会因后续失败被撤回;失败本身追加为新事实。
  5. 恢复必须有原因、有预算、有终点;达到上限必须明确终止,不许静默消失。
  6. 外部可见事件从单一出口产生,最终结果可由权威链路恢复。
  7. 进程内缓存、连接、注册表只能加速当前执行,不能成为可恢复业务状态。

结尾

回到开头的定义,把它串成一句话:

如一是一个以消息为刺激、以 Run 为执行边界、以 Turn 为认知节拍、以持久事实维持连续性、以模型作开放决策、以平台完成确定执行,并最终通过 Message、Artifact 和 Command 改变用户世界的智能运行系统。

这个系列后面还计划写三篇:上下文工程(transcript / 压缩 / 记忆 / 临时投影的边界)、多 Agent 委派(子 Agent 与后台 Worker 的生命周期和血缘)、可靠性设计(幂等、outbox、崩溃恢复的完整链路)。有兴趣可以关注,也欢迎评论区交流你踩过的坑。