Agent 工程体系 · 第 41/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。

AG-UI 协议:Agent 事件、前端状态、流式交互和人工确认

AG-UI(Agent–User Interaction Protocol)是一个面向 Agent 与用户侧应用之间交互的开放、轻量、事件驱动协议。它解决的不是“如何构建 Agent”,也不是“如何让 Agent 调用工具”,而是如何把一个长时间运行、会调用工具、会修改状态、可能需要人工决策的 Agent,可靠地接入聊天页面、业务工作台、移动端、终端或协作平台。(ag-ui.com)

在 Agent 工程体系中,可以把相关协议分成三层:

交互层 协议 解决的问题
Agent ↔ 用户应用 AG-UI Agent 如何向前端发送事件,前端如何提供上下文、工具和人工输入
Agent ↔ 工具与数据 MCP Agent 如何连接外部工具、API、资源和数据
Agent ↔ Agent A2A 不同 Agent 如何发现彼此、委托任务和交换结果

因此,AG-UI 不会取代 MCP 或 A2A。一个典型系统可能是:浏览器通过 AG-UI 连接主 Agent,主 Agent 使用 MCP 访问数据库,再通过 A2A 委托给远程 Agent。AG-UI 负责把最终过程、状态、工具活动和人工确认呈现给用户。(ag-ui.com)


一、为什么传统请求—响应接口不足以承载 Agent

普通接口通常可以抽象为:

RequestResponse\text{Request} \rightarrow \text{Response}

客户端提交请求,服务器完成处理,返回一个结果,交互结束。

但 Agent 的实际执行更接近:

InputPlanTool CallTool ResultState UpdateHuman DecisionContinueFinal Result\text{Input} \rightarrow \text{Plan} \rightarrow \text{Tool Call} \rightarrow \text{Tool Result} \rightarrow \text{State Update} \rightarrow \text{Human Decision} \rightarrow \text{Continue} \rightarrow \text{Final Result}

这个过程有几个重要差异:

  1. 执行时间不确定,可能持续数秒、数分钟甚至更久。
  2. 最终结果不是唯一有价值的信息,中间过程本身也需要展示。
  3. 文本、工具调用、结构化状态、错误和人工输入可能交错出现。
  4. Agent 可能在执行中暂停,等待用户确认或补充信息。
  5. 前端可能在执行期间修改共享状态,Agent 需要看到这些变化。
  6. 网络连接可能断开,但 Agent 本身仍然在执行或已经执行完成。

如果把所有过程压缩成一个最终 JSON,前端就无法知道:

  • 当前是否还在运行;
  • Agent 正在调用什么工具;
  • 工具参数是否已经完整;
  • 页面状态是否已经更新;
  • 当前是否等待人工操作;
  • 断线后应该从哪里恢复;
  • 某个错误发生在模型、工具、协议还是网络层。

AG-UI 的基本做法是把一次运行表示为一组有顺序的事件:

E=e1,e2,,enE = \langle e_1, e_2, \ldots, e_n \rangle

前端不是直接接收“最终页面状态”,而是消费事件序列,并根据事件构造自己的消息、状态和 UI 状态机。AG-UI 官方文档将事件分为生命周期、文本消息、工具调用、状态管理、活动、子 Agent、特殊事件和推理事件等类别。(docs.ag-ui.com)


二、AG-UI 的核心抽象:一次运行、一个线程和一条事件流

2.1 Thread、Run 和 Message

理解 AG-UI 至少需要区分三个概念。

Thread

threadId 表示一条会话或业务流程。例如:

threadId = "order-review-2026-0001"

同一个线程可以包含多次运行:

  • 用户第一次提出请求;
  • Agent 请求补充信息;
  • 用户确认工具调用;
  • Agent 根据确认继续执行;
  • 用户重新编辑参数后再次运行。

Run

runId 表示一次具体的 Agent 执行。一次用户提交通常对应一个新的 runId

threadId = "order-review-2026-0001"
runId    = "run-01"

如果 Agent 因人工确认暂停,原来的运行以中断结果结束,用户提交确认后通常启动新的运行继续处理。AG-UI 的中断模型明确要求恢复请求仍然使用相同的 threadId,但使用新的运行生命周期。(docs.ag-ui.com)

Message

messageId 表示一条消息。文本消息、工具结果、活动消息和推理消息都可能使用消息标识,但不同消息类型的内容结构不同,不能随意复用同一个 ID。

例如:

run-01
├── msg-user-01
├── msg-assistant-01
├── tool-call-01
└── msg-tool-01

runId 解决“哪一次执行”的问题,messageId 解决“哪一条消息”的问题,toolCallId 解决“哪一次工具调用”的问题。混淆这些标识,会导致多个并发工具调用的输出被错误拼接。


2.2 事件的共同属性

AG-UI 事件至少具有事件类型:

{
  "type": "TEXT_MESSAGE_CONTENT"
}

官方事件模型还定义了可选的 timestamprawEventmetadata。其中 metadata 可携带 trace ID、Token 使用量、结束原因等额外信息;消费者通常把同一消息生命周期中的元数据按键合并,后来的值覆盖先前的值。(docs.ag-ui.com)

一个适合生产系统的事件外壳可以是:

{
  "type": "TEXT_MESSAGE_CONTENT",
  "messageId": "msg-assistant-01",
  "delta": "订单已找到",
  "timestamp": "2026-09-01T10:00:01.210Z",
  "metadata": {
    "traceId": "trace-9c2",
    "model": "example-model"
  }
}

这里需要区分三类信息:

  • typemessageIddelta:协议语义;
  • timestamp:事件生成时间;
  • metadata.traceIdmetadata.model:系统观测或实现扩展。

前端不能依赖某个业务自定义元数据来判断运行是否结束。运行是否结束,应由 RUN_FINISHEDRUN_ERROR 表达。


三、生命周期事件:前端首先要知道“运行处于什么状态”

一次正常运行通常以 RUN_STARTED 开始,以 RUN_FINISHED 结束;不可恢复的运行错误以 RUN_ERROR 结束。STEP_STARTEDSTEP_FINISHED 是可选的步骤边界,用于显示更细粒度的进度。(docs.ag-ui.com)

一个最小事件序列如下:

RUN_STARTED
  ├── STEP_STARTED
  ├── TEXT_MESSAGE_START
  ├── TEXT_MESSAGE_CONTENT
  ├── TEXT_MESSAGE_END
  ├── STEP_FINISHED
  └── RUN_FINISHED

前端可以据此建立运行状态:

空闲
  └── RUN_STARTED       → 运行中
                         ├── 普通事件       → 运行中
                         ├── RUN_FINISHED   → 成功
                         └── RUN_ERROR      → 失败

3.1 RUN_STARTED

RUN_STARTED 建立一次运行上下文,通常包含:

{
  "type": "RUN_STARTED",
  "threadId": "thread-01",
  "runId": "run-01",
  "input": {
    "messages": [
      {
        "id": "msg-user-01",
        "role": "user",
        "content": "查询订单 1001"
      }
    ]
  }
}

parentRunId 可用于分支、时间旅行或从历史节点创建新路径。它不是“父任务正在调用子任务”的唯一表达;子 Agent 还有自己的 subagentRunId 机制。(docs.ag-ui.com)

3.2 RUN_FINISHED

RUN_FINISHED 表示该次运行结束,但“结束”不一定意味着最终业务成功。当前中断感知的生命周期支持不同结果:

{
  "type": "RUN_FINISHED",
  "threadId": "thread-01",
  "runId": "run-01",
  "outcome": {
    "type": "success"
  }
}

等待人工输入时:

{
  "type": "RUN_FINISHED",
  "threadId": "thread-01",
  "runId": "run-01",
  "outcome": {
    "type": "interrupt",
    "interrupts": [
      {
        "id": "interrupt-01",
        "reason": "confirmation",
        "message": "即将向客户发送退款通知,是否继续?"
      }
    ]
  }
}

所以前端至少要区分:

RUN_FINISHED + success
RUN_FINISHED + interrupt
RUN_ERROR

把所有 RUN_FINISHED 都渲染成“成功”是常见错误。中断是一个合法的终止状态,意味着当前运行暂停并等待新的用户输入,而不是 Agent 已经完成业务。(docs.ag-ui.com)

3.3 RUN_ERROR

RUN_ERROR 表示该运行遇到无法恢复的错误:

{
  "type": "RUN_ERROR",
  "threadId": "thread-01",
  "runId": "run-01",
  "message": "库存服务超时",
  "code": "INVENTORY_TIMEOUT"
}

协议层只表达“本次运行失败”,不会替应用决定是否重试。是否重试必须结合错误类型:

  • 网络断开:可能只是客户端没有收到终止事件;
  • 工具超时:可能可以重试;
  • 参数校验失败:重试同样参数通常没有意义;
  • 权限失败:应升级权限或请求人工接管;
  • 外部副作用已发生但响应丢失:不能盲目重试。

四、文本流:Delta 不是完整消息,而是消息的增量

4.1 三段式文本生命周期

AG-UI 的标准文本消息生命周期是:

TEXT_MESSAGE_START
TEXT_MESSAGE_CONTENT × N
TEXT_MESSAGE_END

示例:

{"type":"TEXT_MESSAGE_START","messageId":"msg-01","role":"assistant"}
{"type":"TEXT_MESSAGE_CONTENT","messageId":"msg-01","delta":"订单 "}
{"type":"TEXT_MESSAGE_CONTENT","messageId":"msg-01","delta":"1001 "}
{"type":"TEXT_MESSAGE_CONTENT","messageId":"msg-01","delta":"已发货。"}
{"type":"TEXT_MESSAGE_END","messageId":"msg-01"}

前端重建消息的规则是:

Mk+1=Mk+ ⁣ ⁣+ΔkM_{k+1} = M_k \mathbin{+\!\!+} \Delta_k

其中:

  • MkM_k 是当前已经拼接出的文本;
  • Δk\Delta_k 是本次事件的 delta
  • + ⁣ ⁣++\!\!+ 表示按事件到达顺序进行字符串追加。

初始状态为:

M0 = ""

逐步计算:

M1 = ""       + "订单 "
M2 = "订单 "  + "1001 "
M3 = "订单 1001 " + "已发货。"

最终:

M3 = "订单 1001 已发货。"

delta 不是“当前完整文本”。下面这种实现是错误的:

message.content = event.delta;

它会导致前端最终只显示最后一个片段。正确实现是:

message.content += event.delta;

但在真实应用中还需要先按 messageId 找到目标消息,并处理事件乱序、重复事件和重连后的快照覆盖。

4.2 TEXT_MESSAGE_CHUNK

TEXT_MESSAGE_CHUNK 是一种便利事件。客户端或转换器可以把它展开成 START → CONTENT → END

{"type":"TEXT_MESSAGE_CHUNK","messageId":"msg-01","role":"assistant","delta":"订单 "}
{"type":"TEXT_MESSAGE_CHUNK","messageId":"msg-01","delta":"1001 已发货。"}

当消息 ID 切换或流结束时,转换器自动补发结束事件。它减少了生产者的编码量,但前端内部仍然应该按标准三段式生命周期处理,而不是为每种便利事件单独维护一套逻辑。(docs.ag-ui.com)


五、工具流:工具调用参数和工具结果是两个不同阶段

Agent 工具交互至少包含两个阶段:

  1. Agent 生成工具调用及其参数;
  2. 工具实际执行并返回结果。

标准工具调用事件通常是:

TOOL_CALL_START
TOOL_CALL_ARGS × N
TOOL_CALL_END
TOOL_CALL_RESULT

例如:

{"type":"TOOL_CALL_START","toolCallId":"call-01","toolCallName":"getOrder"}
{"type":"TOOL_CALL_ARGS","toolCallId":"call-01","delta":"{\"orderId\":\""}
{"type":"TOOL_CALL_ARGS","toolCallId":"call-01","delta":"1001\"}"}
{"type":"TOOL_CALL_END","toolCallId":"call-01"}
{"type":"TOOL_CALL_RESULT","toolCallId":"call-01","messageId":"tool-msg-01","role":"tool","content":"{\"status\":\"shipped\"}"}

工具参数的重建同样是增量拼接:

A=Δ1+ ⁣ ⁣+Δ2+ ⁣ ⁣++ ⁣ ⁣+ΔnA = \Delta_1 \mathbin{+\!\!+} \Delta_2 \mathbin{+\!\!+} \cdots \mathbin{+\!\!+} \Delta_n

得到:

{"orderId":"1001"}

只有在 TOOL_CALL_END 之后,参数流才算结束。前端不能在收到第一个 TOOL_CALL_ARGS 时就假设 JSON 已经完整,因为参数可能是:

{"order

此时直接解析会失败,但这不一定是协议错误,只是参数尚未传输完毕。

5.1 TOOL_CALL_END 不等于工具执行成功

TOOL_CALL_END 只表示工具调用描述和参数已经传输完毕。在某些实现中,工具还没有执行完成;在另一些实现中,结果可能紧随其后。真正的工具输出由 TOOL_CALL_RESULT 表达。(docs.ag-ui.com)

因此,前端的工具状态应至少包含:

declared      已收到 TOOL_CALL_START
args_streaming 参数仍在传输
ready         已收到 TOOL_CALL_END
executing     等待工具执行
succeeded     收到成功结果
failed        收到工具错误结果或 RUN_ERROR

不要把“Agent 已经决定调用工具”与“工具已经产生副作用”混为一谈。对于转账、发邮件、删除数据等操作,真正的风险边界通常发生在工具执行阶段,而不是 TOOL_CALL_START 阶段。


六、Usage、Finish 和错误:如何放置运行元信息

“Usage”与“Finish”经常出现在模型厂商的流式 API 中,但它们不一定是 AG-UI 中独立的标准事件类型。

在 AG-UI 事件模型中:

  • 运行结束主要由 RUN_FINISHED 表达;
  • 运行失败由 RUN_ERROR 表达;
  • Token 使用量、模型名称、finish reason 等附加数据可以放入事件的 metadata
  • 具体实现也可能通过 CUSTOM 事件传递应用自定义的计量信息。

例如:

{
  "type": "RUN_FINISHED",
  "threadId": "thread-01",
  "runId": "run-01",
  "metadata": {
    "usage": {
      "inputTokens": 812,
      "outputTokens": 143,
      "totalTokens": 955
    },
    "finishReason": "stop"
  },
  "outcome": {
    "type": "success"
  }
}

这里的 usage 是元数据,不应被前端当作所有 AG-UI 实现都必然提供的字段。工程上必须明确:

信息 协议语义 是否一定存在
RUN_FINISHED 运行结束 正常运行必须有
RUN_ERROR 运行失败 仅失败运行存在
usage 计量信息 取决于 Agent、模型和适配器
finishReason 模型或运行结束原因 取决于实现
result 运行结果 可选,取决于实现

这样可以避免把某个模型厂商的响应字段直接误认为 AG-UI 的核心事件。


七、前端状态:Snapshot 建立基线,Delta 进行增量修改

AG-UI 的前端状态同步采用 **快照(Snapshot)+ 增量(Delta)**模式。

设前端状态为 JSON 文档 SS

{
  "orderId": "1001",
  "status": "pending",
  "selectedItems": []
}

7.1 STATE_SNAPSHOT

STATE_SNAPSHOT 传输完整状态:

{
  "type": "STATE_SNAPSHOT",
  "snapshot": {
    "orderId": "1001",
    "status": "pending",
    "selectedItems": []
  }
}

收到快照后,前端应该整体替换当前状态:

state = event.snapshot;

而不是浅合并:

state = { ...state, ...event.snapshot };

原因是快照的语义是“新的完整基线”。如果旧状态中有字段:

{
  "temporaryWarning": "库存不足"
}

而新快照中没有该字段,整体替换会清除旧字段;浅合并则会错误地保留它。

7.2 STATE_DELTA

STATE_DELTA 使用 RFC 6902 JSON Patch 表示对 JSON 文档的修改:

{
  "type": "STATE_DELTA",
  "delta": [
    {
      "op": "replace",
      "path": "/status",
      "value": "shipped"
    },
    {
      "op": "add",
      "path": "/trackingNumber",
      "value": "YT123456"
    }
  ]
}

应用过程为:

Si+1=Pi(Si)S_{i+1} = P_i(S_i)

其中:

  • SiS_i 是应用第 ii 个补丁前的状态;
  • PiP_i 是 JSON Patch 操作集合;
  • Si+1S_{i+1} 是应用补丁后的状态。

从初始状态开始:

S0 = {
  "orderId": "1001",
  "status": "pending",
  "selectedItems": []
}

应用第一个操作:

S1 = {
  "orderId": "1001",
  "status": "shipped",
  "selectedItems": []
}

应用第二个操作:

S2 = {
  "orderId": "1001",
  "status": "shipped",
  "selectedItems": [],
  "trackingNumber": "YT123456"
}

AG-UI 文档将 STATE_DELTA 定义为 JSON Patch 操作数组,并要求前端按顺序应用;如果检测到状态不一致,可以重新请求或接收新的 STATE_SNAPSHOT。(docs.ag-ui.com)

7.3 Delta 的反例:把补丁当成状态

下面的事件:

{
  "type": "STATE_DELTA",
  "delta": [
    {"op":"replace","path":"/status","value":"shipped"}
  ]
}

不是:

{
  "status": "shipped"
}

它只在已有正确基线的状态上有意义。如果前端刚启动,当前状态为空对象,直接应用:

replace /status

可能失败,因为 /status 并不存在。此时前端需要:

  1. 获取完整快照;
  2. 确认事件顺序;
  3. 检查是否丢失了连接前的事件;
  4. 不要通过猜测字段默认值来“修复”状态。

八、消息快照和状态快照不是一回事

MESSAGES_SNAPSHOT 用于同步会话消息历史:

{
  "type": "MESSAGES_SNAPSHOT",
  "messages": [
    {
      "id": "msg-user-01",
      "role": "user",
      "content": "查询订单 1001"
    },
    {
      "id": "msg-assistant-01",
      "role": "assistant",
      "content": "订单 1001 已发货。"
    }
  ]
}

它解决的是“聊天记录是什么”,而 STATE_SNAPSHOT 解决的是“Agent 和前端共享的业务状态是什么”。

两者不能互相替代:

MESSAGES_SNAPSHOT
  → 对话内容、工具消息、活动消息等

STATE_SNAPSHOT
  → 表单、筛选条件、业务对象、流程状态等

人工确认恢复时,协议要求在携带中断结果的 RUN_FINISHED 之前,发送恢复所需的 STATE_SNAPSHOTMESSAGES_SNAPSHOT。这样无论后端采用重放消息的方式,还是采用框架原生 checkpoint 的方式,前端都能看到一致的可观察状态。(docs.ag-ui.com)


九、人工确认:Interrupt 是运行边界,不是普通聊天消息

人工确认(Human-in-the-loop)指 Agent 在执行过程中把决策权交给人,由人确认、修改、澄清、拒绝或升级后再继续。

AG-UI 的中断模型不是简单发送一条:

“请确认是否继续”

而是:

  1. 当前运行暂停;
  2. 当前运行以 RUN_FINISHED 结束;
  3. outcome.type 设置为 interrupt
  4. 事件中携带一个或多个 Interrupt
  5. 用户提交新的运行输入;
  6. 新输入携带对应的 resume 响应。

一个中断对象可以包含:

{
  "id": "interrupt-01",
  "reason": "tool_call",
  "message": "即将执行退款 500 元,是否继续?",
  "toolCallId": "call-refund-01",
  "responseSchema": {
    "type": "object",
    "properties": {
      "approved": {
        "type": "boolean"
      },
      "comment": {
        "type": "string"
      }
    },
    "required": ["approved"]
  },
  "expiresAt": "2026-09-01T10:30:00Z"
}

字段含义如下:

  • id:中断的关联键,用于恢复、幂等和审计;
  • reason:中断原因;
  • message:人可以直接阅读的提示;
  • toolCallId:如果中断绑定工具调用,用于连接工具调用和人工决定;
  • responseSchema:人工响应的 JSON Schema;
  • expiresAt:响应有效期;
  • metadata:框架或业务扩展信息。

官方定义了三个核心原因:

reason 含义
tool_call 某个工具调用等待人工决定
input_required Agent 需要结构化输入
confirmation 独立的确认或是否决策

未知的扩展原因不应让客户端报错。客户端可以退回到通用 UI:显示 message,根据 responseSchema 生成输入控件,并将 metadata 交给应用处理。(docs.ag-ui.com)


十、确认、澄清、升级、接管和恢复的差异

“人工介入”不是一种单一操作。不同介入方式对应不同责任边界。

10.1 确认

确认是对一个已经明确的动作做允许或拒绝:

{
  "reason": "confirmation",
  "message": "是否发送退款通知?"
}

人只需要回答:

{
  "approved": true
}

确认适用于:

  • 发送邮件;
  • 创建订单;
  • 删除数据;
  • 执行付款;
  • 发布内容。

但确认 UI 必须展示实际动作、目标、参数和影响范围。只显示“是否继续”而不显示“继续什么”,无法形成有效授权。

10.2 澄清

澄清表示 Agent 缺少继续执行所需的信息:

{
  "reason": "input_required",
  "message": "请选择退款原因",
  "responseSchema": {
    "type": "object",
    "properties": {
      "reason": {
        "type": "string",
        "enum": ["duplicate", "not_received", "other"]
      }
    },
    "required": ["reason"]
  }
}

用户恢复时提交:

{
  "interruptId": "interrupt-02",
  "status": "resolved",
  "payload": {
    "reason": "not_received"
  }
}

澄清不是确认。把所有人工输入都设计为布尔值,会导致业务信息丢失,也会迫使 Agent 从自然语言中猜测结构化字段。

10.3 升级

升级表示当前 Agent 不应继续自行决策,需要转交更高权限的人或另一个处理队列。例如:

  • 金额超过当前授权额度;
  • 涉及法律或合规判断;
  • 检测到身份风险;
  • 多次工具失败;
  • 业务规则存在冲突。

升级可以使用自定义 reason

{
  "reason": "risk:manual_review",
  "message": "该退款超过当前自动处理额度,需要人工审核。",
  "metadata": {
    "queue": "finance-review",
    "riskLevel": "high"
  }
}

协议允许扩展原因,但建议使用命名空间,例如 <framework>:<name> 或业务域前缀,避免不同系统使用相同字符串表达不同语义。(docs.ag-ui.com)

10.4 接管

接管不是简单地把一个按钮改成“人工处理”。它意味着责任主体从 Agent 转移给人:

Agent 执行
  → 发现高风险动作
  → 暂停
  → 人工接管
  → 人执行或修改动作
  → 记录最终决定

接管后的人工动作必须写入审计记录,至少包括:

threadId
原始 runId
interruptId
原始工具参数
人工身份
人工修改后的参数
最终决定
决定时间
外部副作用结果

AG-UI 可以传递中断和恢复信息,但不会自动替业务系统完成身份认证、权限校验、电子签名或不可抵赖审计。这些属于应用和治理层责任。

10.5 恢复

恢复是指人工响应提交后,Agent 继续执行。恢复不是“在原来的 HTTP 连接上继续发送”,而是通过新的运行输入携带 resume

{
  "threadId": "thread-01",
  "runId": "run-02",
  "resume": [
    {
      "interruptId": "interrupt-01",
      "status": "resolved",
      "payload": {
        "approved": true,
        "comment": "已核对订单"
      }
    }
  ]
}

AG-UI 对恢复有几个严格约束:

  1. 必须使用同一个 threadId
  2. interruptId 必须对应之前打开的中断;
  3. 一次恢复必须覆盖该次运行的全部未解决中断;
  4. 如果线程仍有待处理的中断,新的输入必须携带 resume
  5. 相同恢复请求应当可以安全重放;
  6. payload 应符合 responseSchema
  7. 超过 expiresAt 的恢复必须失败,而不能静默继续。(docs.ag-ui.com)

十一、一个完整的人工确认时序

sequenceDiagram
    participant U as 用户
    participant F as 前端
    participant A as Agent
    participant T as 工具

    U->>F: 提交“退款订单 1001”
    F->>A: RunAgentInput(threadId, runId=run-01)
    A-->>F: RUN_STARTED
    A-->>F: TOOL_CALL_START(refund)
    A-->>F: TOOL_CALL_ARGS(amount=500)
    A-->>F: TOOL_CALL_END
    A-->>F: STATE_SNAPSHOT / MESSAGES_SNAPSHOT
    A-->>F: RUN_FINISHED(outcome=interrupt)

    F->>U: 展示退款金额、订单和风险
    U->>F: 确认退款
    F->>A: RunAgentInput(runId=run-02, resume=[...])
    A-->>F: RUN_STARTED
    A->>T: 执行退款工具
    T-->>A: 退款成功
    A-->>F: TOOL_CALL_RESULT
    A-->>F: STATE_DELTA(status=refunded)
    A-->>F: TEXT_MESSAGE_CONTENT
    A-->>F: TEXT_MESSAGE_END
    A-->>F: RUN_FINISHED(outcome=success)

关键点是:人工确认发生在工具副作用之前。TOOL_CALL_ARGS 只是 Agent 提出的动作,不能被前端误认为动作已经执行。

如果工具已经执行,再让用户确认“是否执行”,确认就失去了安全意义。这个错误通常来自后端把工具调用和工具执行放在同一个不可暂停函数中。


十二、客户端如何消费事件流

下面给出一个不依赖具体框架的 TypeScript 风格实现。它展示的是协议处理逻辑,不假设某个特定 UI 框架。

type Event = {
  type: string;
  messageId?: string;
  runId?: string;
  threadId?: string;
  delta?: string;
  snapshot?: unknown;
  messages?: unknown[];
};

type ClientState = {
  runStatus: "idle" | "running" | "success" | "interrupted" | "error";
  messages: Record<string, { role?: string; content: string }>;
  state: unknown;
  error?: string;
};

function applyEvent(s: ClientState, event: Event): ClientState {
  switch (event.type) {
    case "RUN_STARTED":
      return {
        ...s,
        runStatus: "running",
        error: undefined,
      };

    case "TEXT_MESSAGE_START":
      if (!event.messageId) return s;

      return {
        ...s,
        messages: {
          ...s.messages,
          [event.messageId]: {
            role: event.role,
            content: "",
          },
        },
      };

    case "TEXT_MESSAGE_CONTENT":
      if (!event.messageId || event.delta == null) return s;

      const oldMessage = s.messages[event.messageId];
      if (!oldMessage) {
        // 生产实现应记录协议顺序异常,而不是悄悄丢弃
        return s;
      }

      return {
        ...s,
        messages: {
          ...s.messages,
          [event.messageId]: {
            ...oldMessage,
            content: oldMessage.content + event.delta,
          },
        },
      };

    case "STATE_SNAPSHOT":
      return {
        ...s,
        state: event.snapshot,
      };

    case "RUN_FINISHED":
      if ((event as any).outcome?.type === "interrupt") {
        return { ...s, runStatus: "interrupted" };
      }
      return { ...s, runStatus: "success" };

    case "RUN_ERROR":
      return {
        ...s,
        runStatus: "error",
        error: (event as any).message ?? "Agent run failed",
      };

    default:
      // 未知事件应保留观测能力,不能影响已知事件处理
      return s;
  }
}

前置条件是:

  • 传输层已经把每个事件解析为一个 JSON 对象;
  • 事件顺序至少在同一条逻辑流内保持一致;
  • 前端能够根据 messageId 找到目标消息;
  • JSON Patch 的应用由经过验证的库完成,而不是手写字符串替换。

示例中没有处理 STATE_DELTA,因为 JSON Patch 的 movecopytest、数组索引和 JSON Pointer 转义都容易产生错误。生产实现应使用符合 RFC 6902 的库,并在补丁失败后触发重新同步,而不是自行猜测正确状态。


十三、传输层:AG-UI 定义事件,不强制定义唯一传输方式

AG-UI 的协议层与传输层分离。官方文档列出的实现方式包括 HTTP SSE、WebSocket、Webhook 等;参考 HTTP 客户端可以通过 HTTP SSE 或二进制传输接收事件。(docs.ag-ui.com)

13.1 SSE 适合什么场景

SSE 的特点是:

  • 浏览器支持直接接收服务端事件;
  • 服务端到客户端流式传输简单;
  • 便于调试;
  • 客户端到服务端仍需要普通 HTTP 请求提交输入。

因此可以采用:

POST /agent/run
  请求体:RunAgentInput

响应:
  Content-Type: text/event-stream
  data: {"type":"RUN_STARTED",...}

  data: {"type":"TEXT_MESSAGE_CONTENT",...}

  data: {"type":"RUN_FINISHED",...}

但 SSE 本身不负责:

  • 事件持久化;
  • 断线后的精确续传;
  • 幂等;
  • 多标签页竞争;
  • 人工审批权限;
  • 工具副作用去重。

这些能力必须由应用层补齐。

13.2 WebSocket 适合什么场景

WebSocket 可以支持双向长连接,让前端事件和用户输入复用同一连接。但它也不会自动解决状态一致性。连接断开后,客户端仍然需要依据 threadIdrunId、事件序号或服务端快照进行恢复。


十四、断线、重复和恢复:事件流不是可靠消息队列

网络连接断开时,客户端可能处于以下任一状态:

A. Agent 尚未收到请求
B. Agent 已收到请求但尚未开始运行
C. Agent 正在运行
D. Agent 已完成但 RUN_FINISHED 尚未到达客户端
E. Agent 已暂停等待人工确认
F. Agent 已失败但 RUN_ERROR 尚未到达客户端

客户端无法仅凭“连接断开”判断是哪一种情况。

因此,恢复设计应把“传输连接”与“业务运行”分开:

连接状态:connected / disconnected
运行状态:running / success / interrupted / error / unknown

断线后建议按以下顺序处理:

  1. 使用已有 threadIdrunId 查询运行状态;
  2. 如果服务端保存事件日志,从最后确认位置继续读取;
  3. 如果无法确认事件位置,获取 MESSAGES_SNAPSHOTSTATE_SNAPSHOT
  4. 如果运行仍处于执行中,重新订阅而不是重复启动;
  5. 如果运行已经中断,展示待处理的 Interrupt
  6. 只有在明确需要继续执行时,才提交新的 resume

AG-UI 的序列化模型支持保存完整事件历史、压缩事件、恢复会话以及通过 parentRunId 建立分支。事件压缩可以把多个文本增量合并,把状态增量折叠为快照,但压缩不能改变最终可观察语义。(docs.ag-ui.com)

14.1 为什么不能简单重试原请求

假设客户端发送了退款请求:

请求已发出
工具可能已经执行
客户端在等待结果时断线

此时自动重试原始 Agent 输入,可能造成两次退款。

可靠做法是为每个有副作用的工具建立幂等键:

idempotencyKey =
  hash(threadId + businessActionId + toolName + normalizedArguments)

工具服务端收到同一个幂等键时:

  • 如果第一次执行成功,返回原结果;
  • 如果第一次正在执行,返回处理中;
  • 如果第一次执行失败,根据错误类型决定是否允许重试;
  • 不得因为客户端重复提交就无条件再次产生副作用。

这属于生产经验建议,不是 AG-UI 单独保证的能力。


十五、并发:多个工具和子 Agent 的事件不能靠到达顺序猜来源

Agent 可能同时运行多个工具:

call-01: 查询库存
call-02: 查询物流
call-03: 读取客户等级

事件到达顺序可能是:

TOOL_CALL_START(call-01)
TOOL_CALL_START(call-02)
TOOL_CALL_ARGS(call-01)
TOOL_CALL_ARGS(call-02)
TOOL_CALL_END(call-02)
TOOL_CALL_RESULT(call-02)
TOOL_CALL_END(call-01)
TOOL_CALL_RESULT(call-01)

前端必须通过 toolCallId 分桶,而不能假设所有工具调用严格串行。

子 Agent 也可能并发运行。AG-UI 使用 subagentRunId 标记某个子 Agent 调用,并通过 SUBAGENT_STARTEDSUBAGENT_FINISHEDSUBAGENT_ERROR 描述其边界。即使多个子 Agent 同时输出,前端仍可以根据标识把内容归属到正确的执行分支。(docs.ag-ui.com)

需要特别注意:子 Agent 的状态更新是“归属信息”,不是独立的子 Agent 状态文档。官方文档明确说明,状态仍然作用于运行级别的单一状态文档,不会自动产生每个子 Agent 一份独立状态。(docs.ag-ui.com)


十六、AG-UI 与 A2A 的边界

A2A 的目标是让不同 Agent 之间进行通信、协作、任务委托和结果交换;AG-UI 的目标是让 Agent 与用户侧应用进行事件交互。A2A 官方文档将其定位为 Agent 间互操作协议,并强调它与 MCP 是互补关系。(a2a-protocol.org)

一个组合示例如下:

浏览器
  │ AG-UI
  ▼
主 Agent
  │ MCP
  ├── 数据库工具
  ├── 工单系统工具
  │
  └── A2A
       ▼
     风控 Agent

其中:

  • 浏览器不需要知道风控 Agent 的内部实现;
  • 主 Agent 不需要把风控 Agent 的内部工具暴露给浏览器;
  • 风控 Agent 的中间过程可以被主 Agent 转换为 AG-UI 事件;
  • 用户确认最终仍然发生在用户应用与主 Agent 的交互边界。

不要把 A2A 的任务状态对象直接当作 AG-UI 的前端状态。A2A 的任务生命周期、Artifact 和消息模型服务于 Agent 间协作;AG-UI 的事件则服务于前端渲染、用户输入和交互控制。两者可以通过适配器连接,但不是同一种事件模型。


十七、常见错误及诊断方法

错误一:只发送最终答案

表现:

  • 页面长时间空白;
  • 用户不知道 Agent 是否卡住;
  • 无法显示工具过程;
  • 无法在中途确认或取消。

诊断:

检查服务端是否至少发送:

RUN_STARTED
中间事件
RUN_FINISHED 或 RUN_ERROR

如果只有一个最终 JSON,说明系统仍然是请求—响应接口,而不是完整的 Agent 事件流。

错误二:把 Delta 当完整值

表现:

页面最终只显示“发货。”

原因:

每次收到 delta 都覆盖了旧内容。

修复:

messageId 累加,并在 TEXT_MESSAGE_END 后将消息标记为完成。

错误三:用浅合并处理 StateSnapshot

表现:

断线重连后,页面残留旧的表单字段、旧告警或旧工具状态。

原因:

把完整快照误当成部分更新。

修复:

STATE_SNAPSHOT 整体替换;STATE_DELTA 才按补丁顺序应用。

错误四:收到 ToolCallEnd 就执行工具

表现:

人工确认还没有出现,退款或删除操作已经发生。

原因:

把“工具参数传输完成”与“工具实际执行”绑定为同一个动作。

修复:

对于需要审批的工具,在执行副作用前生成中断,并等待合法的 resume

错误五:中断后重新发送普通用户消息

表现:

服务端返回“仍有待处理的中断”,或者同一动作被重复执行。

原因:

线程中存在未解决中断,但新输入没有覆盖 resume

修复:

使用原 threadId,为所有打开的中断提交 resume;不要仅提交一个新的自然语言消息。(docs.ag-ui.com)

错误六:把人工拒绝建模为取消

AG-UI 的 cancelled 表示用户放弃提供输入;业务上的“拒绝执行”通常应作为已解决的结构化响应:

{
  "interruptId": "interrupt-01",
  "status": "resolved",
  "payload": {
    "approved": false,
    "reason": "金额不正确"
  }
}

如果把拒绝错误地表示成 cancelled,Agent 可能无法区分:

用户明确拒绝
用户关闭页面
用户超时离开

这三者在审计、重试和业务统计上可能完全不同。


十八、生产实现需要明确的责任边界

AG-UI 能够标准化事件和交互,但以下责任不应被错误地归因于协议本身:

协议负责

  • 事件类型和基本事件结构;
  • 文本、工具、状态和生命周期的表达;
  • 中断与恢复的关联关系;
  • 快照与增量更新的语义;
  • 事件流的互操作基础。

Agent 运行时负责

  • 生成正确的事件顺序;
  • 保存可恢复状态;
  • 正确暂停和恢复执行;
  • 对工具调用进行权限和参数校验;
  • 避免输出敏感内部推理;
  • 维护运行和任务的幂等性。

前端负责

  • 按 ID 正确归并事件;
  • 按顺序拼接 Delta;
  • 用 JSON Patch 更新状态;
  • 对未知扩展事件降级处理;
  • 展示人工确认所需的完整上下文;
  • 防止用户重复提交恢复请求。

业务系统负责

  • 用户身份认证;
  • 操作权限;
  • 审批人资格;
  • 工具副作用控制;
  • 幂等键;
  • 审计和合规;
  • 敏感数据脱敏;
  • 接管后的责任追踪。

特别是,前端显示了“已确认”并不等于业务动作已经成功。确认只是向 Agent 传递了一个用户决定;最终结果仍必须由工具结果、业务事务状态或外部系统回执确认。


十九、实现检查:一条可验证的 AG-UI 运行至少应满足什么条件

可以用以下最小不变量检查实现:

生命周期不变量

每个 runId:
  有且只有一个 RUN_STARTED
  最终有 RUN_FINISHED 或 RUN_ERROR

文本不变量

每个 messageId:
  START 之后才能接收 CONTENT
  END 之后不再接收 CONTENT

工具不变量

每个 toolCallId:
  START → ARGS* → END
  RESULT 必须能关联到对应 toolCallId

状态不变量

STATE_SNAPSHOT:整体替换
STATE_DELTA:按顺序应用
补丁失败:触发重新同步,而不是静默忽略

中断不变量

RUN_FINISHED(outcome=interrupt)
  → interrupt.id 非空
  → interrupts 数组非空
  → 后续恢复使用同一个 threadId
  → resume 覆盖全部打开的 interrupt

幂等不变量

相同的恢复请求重复到达:
  不应重复产生人工决定的业务副作用

这些不变量比“页面能否显示文字”更重要。一个能够显示流式文本、但会在断线后重复退款的实现,不能被视为可靠的 AG-UI Agent 应用。

AG-UI 的核心价值并不只是把 Token 逐个显示在页面上,而是为 Agent 的生命周期、事件、工具、共享状态和人工决策建立共同的可观察边界。只有把 Delta 看作增量、把 Snapshot 看作同步基线、把 RUN_FINISHED 与中断结果区分开,并把人工确认设计成可校验、可恢复、可审计的运行边界,前端才真正能够承载长时间运行且具有现实副作用的 Agent。


系列导航与关联阅读

官方资料

本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。