先看产品:能提问、能选择、能继续办事的 AI 助手
用户在同一个聊天窗口里描述目标;助手缺信息时展示选择卡,拿到答案后继续查询;需要写入业务系统时,展示操作摘要并等待用户确认。

自行绘制的产品效果示意,参考常见聊天助手的消息气泡与会话内卡片布局;客户数量与操作均为教学样例。
产品目标: 让用户沿着一段对话完成"表达需求 → 补齐条件 → 获得结果 → 确认操作",每一步都能看见当前状态,并知道下一步需要自己做什么。
本文以虚构的信贷业务样例说明通用交互实现,界面和客户数量均为教学数据,不涉及真实客户信息。
文章目录
-
- [先看产品:能提问、能选择、能继续办事的 AI 助手](#先看产品:能提问、能选择、能继续办事的 AI 助手)
- 系统由前端、后端、大模型组成
- [AG-UI 是前端和 Agent 后端共同遵守的消息约定](#AG-UI 是前端和 Agent 后端共同遵守的消息约定)
-
- 把响应流里的一条消息拆开看
- [前端读懂 type,才知道怎样更新界面](#前端读懂 type,才知道怎样更新界面)
- [一次 HTTP 响应里,内容陆续到达](#一次 HTTP 响应里,内容陆续到达)
- 前端怎样启动一轮对话
- 协议传事件,前端组件负责显示
- 流式响应把工具调用拆成一组可识别事件
- [用户选择后,前端把 ToolMessage 交回去](#用户选择后,前端把 ToolMessage 交回去)
-
- [普通文字回答也是同一条 SSE 通道](#普通文字回答也是同一条 SSE 通道)
- 审批适合显式暂停与恢复
- 动态从哪里来,哪些仍由工程师控制
系统由前端、后端、大模型组成
核心不是后端猜用户想点哪个控件,而是:模型按已登记的工具 Schema 返回结构化调用;Agent Runtime 按工具名分流;前端用预先登记的组件渲染;用户答案再以协议消息交回运行时。
贯穿案例: 用户说"帮我筛选授信未用客户"。上下文里缺少机构,助手发起动态追问;用户选"城东支行";后端核验权限并查询;模型总结结果。

三层职责与消息主路径。HTTP 是传输,SSE 是流式承载,AG-UI 约定事件和消息语义。
前端
维护消息列表和组件注册表;发起运行;消费事件;收集点击、选择等用户输入。
后端 Agent Runtime
校验会话与权限;组合上下文和工具;调用模型;执行后端业务工具;保存和恢复运行状态。
大模型
读取上下文和工具 Schema,产生自然语言、工具调用或结构化参数。模型提出意图,应用决定是否允许执行。
三个关联键: threadId 标识对话;runId 标识一次运行;toolCallId 关联工具调用与对应结果。
AG-UI 是前端和 Agent 后端共同遵守的消息约定
AG-UI 全称 Agent--User Interaction Protocol。 它是开放、基于事件的应用交互协议,规定运行状态、文字、工具调用和用户回应的消息结构与处理语义。前端和 Agent 后端按同一份规范实现,就能正确交换这些信息。AG-UI 官方定义
把响应流里的一条消息拆开看
text
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"m-1","delta":"你好"}
│ └── AG-UI 事件 JSON:事件类型、消息编号、内容片段
└── SSE 的 data 字段前缀;一条事件以空行结束
data: 前缀是 SSE 的字段格式;后面的 JSON 是 AG-UI 定义的事件。这条事件要求前端:找到 m-1 号消息,把"你好"追加到它的文本里。
| 这段内容 | 属于哪一层 | 具体含义 |
|---|---|---|
POST /agent/run HTTP/1.1 200 OK |
HTTP | 发起请求、返回成功响应;/agent/run 是本文自定义地址,AG-UI 不强制固定路由。 |
请求 JSON 中的 threadId / runId / messages / tools / context / forwardedProps |
AG-UI 的 RunAgentInput | 标识对话、运行、输入消息和本轮可用的客户端工具。请求体也体现 AG-UI 约定。 |
Content-Type: text/event-stream data: ... 和空行 |
SSE | HTTP 响应头声明事件流;响应正文用 SSE 字段与空行划分一条条事件。 |
{"type":"TEXT_MESSAGE_CONTENT", ...} |
AG-UI 事件 | 事件名、字段结构及处理规则由协议定义;m-1、实际文本等字段值由本次运行产生。 |
前端读懂 type,才知道怎样更新界面
| AG-UI 事件 | 前端对应行为 |
|---|---|
RUN_STARTED |
记录 threadId / runId,显示运行中状态。 |
TEXT_MESSAGE_START |
按 messageId 创建助手消息气泡。 |
TEXT_MESSAGE_CONTENT |
把 delta 追加到对应消息气泡;同一条消息可收到多次。 |
TEXT_MESSAGE_END |
标记这条文本消息接收完毕。 |
RUN_FINISHED |
标记本轮结束;若 outcome 是 interrupt,进入等待用户回应的状态。 |
AG-UI 约定结构与含义: 只有 SSE 的 data:,还不能表达"追加文字"或"调用工具";里面采用 AG-UI 的事件结构后,前端才可按标准事件分发。工具名 ask_user 和组件样式属于本产品的实现。
HTTP 负责请求响应;SSE 在响应中承载事件;AG-UI 定义事件内容。REST 是接口设计风格,可以与这套交互共存。AG-UI 事件规范 · 输入与消息类型
一次 HTTP 响应里,内容陆续到达
SSE 全称 Server-Sent Events,服务端发送事件。 服务器开始返回响应后保持连接,把事件逐条写入同一个响应体;前端边接收边更新界面。下面展示"你好,我能帮你做什么?"分成三段返回。实际发送间隔由生成与发送过程决定。
http
POST /agent/run
Content-Type: application/json
{"threadId":"th-demo","runId":"run-1","messages":[{"id":"u-1","role":"user","content":"你好"}],"tools":[],"context":[],"forwardedProps":{}}
HTTP/1.1 200 OK
Content-Type: text/event-stream
data: {"type":"RUN_STARTED","threadId":"th-demo","runId":"run-1"}
data: {"type":"TEXT_MESSAGE_START","messageId":"m-1","role":"assistant"}
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"m-1","delta":"你好"}
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"m-1","delta":",我能帮你"}
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"m-1","delta":"做什么?"}
data: {"type":"TEXT_MESSAGE_END","messageId":"m-1"}
data: {"type":"RUN_FINISHED","threadId":"th-demo","runId":"run-1"}
线上正文就是这些文本: 每条 data: 后面跟事件数据,以空行结束一条事件。代码块中的空行有实际作用。SSE 使用 UTF-8 文本,允许数据是普通文字或 JSON;本例数据采用 AG-UI 的 JSON 事件结构。
| 陆续收到的内容片段 | 前端累计显示 |
|---|---|
第一段 delta:你好 |
你好 |
第二段 delta:,我能帮你 |
你好,我能帮你 |
第三段 delta:做什么? |
你好,我能帮你做什么? |
前端消费顺序: 接收网络字节 → 流式解码文本 → SSE 解析器组装完整事件 → JSON 解析 → 按 AG-UI 的 type 分发 → 把 delta 追加到气泡。一次网络读取可能只有半条事件,也可能包含多条;不能把每次读取的内容直接当完整 JSON。生产实现优先使用 SDK 或成熟 SSE 解析器。
两个方向要说清: SSE 本身是服务端到浏览器的单向流。用户回答问题时,浏览器再发一个 HTTP POST;完整对话由这两种方向的消息共同完成。原生 EventSource 发起 GET;本文的 POST 流通常由 SDK 或 fetch + ReadableStream 消费。
服务端需要及时发送并避免中间层把事件攒到最后一起返回。SSE 的可选字段还包括 event、id、retry;这些 SSE 字段与 JSON 里的 AG-UI type / messageId 各有用途。SSE 格式与使用说明
前端怎样启动一轮对话
浏览器通过 HTTP POST 发给 Agent 接口。这里 tools 只放客户端提供的交互能力,例如 ask_user;查询客户的业务工具在后端单独注册,不能让浏览器直接调用数据库。
http
POST /agent/run
Content-Type: application/json
{
"threadId": "th-001",
"runId": "run-001",
"messages": [{"id":"m-1","role":"user","content":"帮我筛选授信未用客户"}],
"tools": [{
"name": "ask_user",
"description": "缺少必需条件时向用户收集信息",
"parameters": {
"type": "object",
"properties": {
"field": {"type":"string","enum":["org_id","period"]},
"question": {"type":"string"}
},
"required": ["field","question"]
}
}],
"context": [],
"forwardedProps": {}
}
Agent Runtime 把历史消息、系统规则和可用工具交给大模型。模型并不是回一句"请选机构"就让前端猜;它会按函数调用格式给出工具名和参数。
text
模型服务响应(OpenAI 兼容格式,省略无关字段)
{
"choices": [{
"message": {
"role": "assistant",
"tool_calls": [{
"id":"call-17", "type":"function",
"function": {
"name":"ask_user",
"arguments":"{\"field\":\"org_id\",\"question\":\"请选择分析机构\"}"
}
}]
},
"finish_reason":"tool_calls"
}]
}
为什么后端"死代码"也能灵活? 代码里预先实现的是工具注册、Schema 校验和分发规则;每轮"调用哪个工具、参数是什么"由模型按 Schema 生成。运行时按 name 精确匹配登记表,不靠搜索"确认""机构"等自然语言关键词。
但"缺什么业务条件"不能全靠模型猜:后端根据任务必填字段和现有上下文做确定性校验;模型负责用自然语言组织追问。若机构可选范围由登录人权限决定,模型不知道名单,后端权限服务才是来源。
协议传事件,前端组件负责显示
AG-UI 给的是工具、消息和事件结构,不会替产品提供授权卡片、下拉框的视觉样式。前端工程师预先实现组件,再登记"什么工具/字段对应什么组件"。模型只填受限参数;不允许它返回任意 HTML 或任意组件名。

| 结构化意图 | 前端预设组件 | 数据与权限由谁负责 |
|---|---|---|
field=org_id |
机构下拉框 SelectField | 前端调用带登录态的选项接口;后端按当前用户权限返回机构列表,并在提交后再校验。 |
field=period |
单选、日期或范围选择器 | 控件类型和可选范围由字段配置决定;模型不提供真实业务选项。 |
confirm_action |
操作摘要 + 同意/取消的 ApprovalCard | 后端持有待执行 action;确认后仍由后端核验、记录审计并执行。 |
request_authorization |
AuthorizationCard / 连接账号按钮 | 后端生成 OAuth/SSO 授权地址并核验回调;按钮点击本身不代表授权完成。 |
前端注册表做三件事: ① 校验工具名和参数 Schema;② 将字段或工具名映射到已实现的组件;③ 组件提交时调用受控接口或把回答交回 Agent。遇到未知工具、未知字段或非法参数,应拒绝渲染/执行并显示安全错误。
下拉框还是单选,推荐由前端字段元数据决定,例如 org_id → SelectField。如果让模型给 ui,也只能从 select / radio 这样的枚举里选,前端仍要二次校验。问题文本可动态生成,组件种类和权限边界保持可控。
流式响应把工具调用拆成一组可识别事件
HTTP 仍然是浏览器到后端的请求协议;请求使用 POST,响应可以用 SSE 持续返回。AG-UI 规定每条事件的类型和字段。前端收到同一个 toolCallId 的参数片段后按顺序拼起来:
http
Content-Type: text/event-stream
data: {"type":"RUN_STARTED","threadId":"th-001","runId":"run-001"}
data: {"type":"TOOL_CALL_START","toolCallId":"call-17","toolCallName":"ask_user"}
data: {"type":"TOOL_CALL_ARGS","toolCallId":"call-17","delta":"{\"field\":"}
data: {"type":"TOOL_CALL_ARGS","toolCallId":"call-17","delta":"\"org_id\",\"question\":\"请选择分析机构\"}"}
data: {"type":"TOOL_CALL_END","toolCallId":"call-17"}
data: {"type":"RUN_FINISHED","threadId":"th-001","runId":"run-001"}
TOOL_CALL_END 只说明参数片段传完了。前端解析 JSON、按注册表找到 ask_user,再根据 field=org_id 渲染下拉组件;它不表示用户已经作答。
用户选择后,前端把 ToolMessage 交回去
用户选了"城东支行",前端以同一 threadId、新 runId 再发一轮。下面只展示本轮 RunAgentInput 的核心字段;生产请求还会带客户端 tools、context 等字段,完整历史或服务端会话引用由具体集成决定。
http
POST /agent/run
{
"threadId": "th-001",
"runId": "run-002",
"messages": [
{"id":"m-2","role":"assistant","toolCalls":[{"id":"call-17","type":"function","function":{"name":"ask_user","arguments":"{\"field\":\"org_id\",\"question\":\"请选择分析机构\"}"}}]},
{"id":"m-3","role":"tool","toolCallId":"call-17","content":"{\"selectedId\":\"org_101\"}"}
]
}
后端收到后用登录会话重新校验 org_101 是否可访问,再把工具结果放进模型上下文。绝不能仅相信前端传回的 label 或模型生成的权限信息。
后端业务工具是另一条路线: 模型调用服务端注册的 query_unused_credit_customers → Runtime 按后端 allowlist 找 handler → 校验身份、机构、参数 → 调业务系统 → 结果以 tool message / ToolCallResult 关联原 toolCallId → 再调模型整理答案。
普通文字回答也是同一条 SSE 通道
http
data: {"type":"TEXT_MESSAGE_START","messageId":"msg-42","role":"assistant"}
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"msg-42","delta":"筛选完成,找到 18 位客户。"}
data: {"type":"TEXT_MESSAGE_END","messageId":"msg-42"}
前端按 messageId 将 delta 逐段追加到同一个聊天气泡;上面的工具事件与普通文本事件都走同一 SSE 流,事件类型决定该如何更新界面。
审批适合显式暂停与恢复
例如模型准备"创建授信跟进任务",这是后端写操作。Runtime 不应看到模型 tool_call 就立刻执行,而应先生成待审批 action,让 AG-UI 以 interrupt 结束本轮;前端根据 interrupt 的类型显示确认卡。
text
响应流中的暂停事件(关键字段)
{
"type": "RUN_FINISHED", "threadId": "th-009", "runId": "run-008",
"outcome": {"type":"interrupt","interrupts":[{
"id":"int-5", "reason":"tool_call", "toolCallId":"call-9",
"message":"确认创建授信跟进任务?",
"responseSchema":{"type":"object","properties":{"approved":{"type":"boolean"}},"required":["approved"]}
}]}
}
用户确认后,前端在同一对话启动新 run,并通过 resume 带回 interruptId 和回答。后端校验结构与权限,再执行或拒绝该 action;执行结果回到工具调用链。普通追问可走上文的客户端工具 + ToolMessage;涉及人工审批时,interrupt/resume 能清楚表达"运行已暂停,等待人决定"。
http
POST /agent/run
{"threadId":"th-009","runId":"run-009","resume":[
{"interruptId":"int-5","status":"resolved","payload":{"approved":true}}
]}
动态从哪里来,哪些仍由工程师控制
| 动态内容 | 谁产生 / 谁校验 |
|---|---|
| 缺哪个字段、问题怎么措辞、调用什么工具 | 大模型按上下文和工具 Schema 产生结构化调用;Runtime 校验工具名与参数。 |
| 问题组件、下拉/单选/确认样式 | 前端组件库与字段注册表预先实现;模型只触发允许的组件能力,不生成 UI 代码。 |
| 机构列表、用户权限、是否允许执行 | 后端身份、权限与业务服务决定;每次请求重新校验。 |
| 等待、恢复、失败、重复提交 | Agent Runtime 保存 thread/run/interrupt 状态;用幂等键、超时和错误事件处理边界情况。 |
可以这样概括: "我把交互建模为受 Schema 约束的工具调用。前端预先实现组件并注册客户端工具,AG-UI 把模型产生的 tool call 通过事件流传到前端;用户输入再以 ToolMessage 或 interrupt/resume 回到同一个 thread。Agent Runtime 负责模型循环、后端工具执行、权限校验和状态恢复。这样交互内容可动态变化,UI 和业务权限仍由工程规则控制。"
协议细节可核对:AG-UI Tools(客户端工具、参数 Schema、ToolMessage);AG-UI Events(SSE 事件、文本和工具调用事件);AG-UI Interrupts(暂停与 resume);AG-UI Core Types(RunAgentInput 与消息结构)。