Skip to content
现代 AI 产品前端架构:从 Chat UI 到 Agent Interface
概述
传统 Web 应用的前端交互对象是「页面」和「表单」。用户通过点击、输入和提交来触发后台操作,后台返回完整页面或局部数据。引入大语言模型(LLM)后,交互对象变成了一段可以生成文本、调用工具、维护上下文的多轮对话。
早期 AI 产品普遍采用 Chat UI,把所有交互收敛到一个聊天窗口中:用户发文本,模型回文本。Mendix 的 Conversational UI 是一组基于生成式 AI 的聊天界面模块,提供会话数据模型、模型上下文设置、交互追踪等能力 [1]。Chat UI 适合问答、内容生成、客服等场景,界面模式主要由消息列表和输入框组成。
Agent 类产品无法被「聊天」覆盖。Agent 的目标是代替用户完成一个任务,中间可能包含多个步骤:模型先理解目标,再决定调用工具,工具返回结果后继续推理,必要时还要向用户确认。用户的关注点从「模型说了什么」转移到「任务进展、工具是否安全、结果是否正确」。这种变化要求前端从 Chat UI 演进为 Agent Interface。
两种界面模式的差异体现在三个层面:
- 界面单元从「消息文本」扩展到「任务、步骤、工具调用、工作产物」。
- 交互方式从「发送-回复」变为「发送-执行-反馈-确认-继续」。
- 状态维度从「会话上下文」扩展到「任务执行状态、工具调用状态、权限确认状态」。
因此,前端架构需要同时表达三类信息:对话流、工具执行流、任务状态流。后文依次讨论这三类信息的建模、传输、渲染和交互处理。
基本概念
消息
消息是对话的最小单元。在 LLM API 中,消息带有角色字段,常见角色包括 system、user、assistant、tool。OpenAI function calling 的流程中,模型返回的 assistant 消息可能包含 tool_calls 字段 [3]:
json
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\": \"Beijing\"}"
}
}
]
}这个示例有两个需要前端注意的细节:
content为null:当模型决定调用工具时,通常不再生成普通文本。arguments是 JSON 字符串,不是对象。前端需要先JSON.parse,再把参数展示给用户或传给执行函数。
前端消息模型可以设计为统一结构:
js
const message = {
id: 'msg_123',
role: 'assistant',
content: '正在查询天气…',
toolCalls: null,
status: 'streaming',
createdAt: Date.now()
};这里的 status 是前端界面状态,例如 streaming、completed、interrupted,不是 LLM API 的标准字段。
任务
任务是独立的执行单元。一个会话可以包含多个任务,一个任务可以包含多个步骤。任务状态通常用状态机表达:
pending → running → waiting_confirmation
→ completed
→ failed
→ canceled前端需要知道任务是否正在等待模型推理、是否在执行工具、是否等待用户授权。这些状态最终反映为界面上的按钮、进度条和提示文案。
工具调用生命周期与 Human-in-the-loop
工具调用(function calling)是 Agent 执行任务的关键能力。API 层的工作流程如下 [3]:
- 应用把可用工具列表传给模型。
- 模型决定调用哪个工具,返回工具调用请求。
- 应用执行函数,获得结果。
- 把工具执行结果作为消息追加到会话。
- 模型根据结果生成最终回复或发起新的工具调用。
以 Node.js 实现核心流程:
js
for (const toolCall of assistantMessage.tool_calls) {
if (toolCall.type !== 'function') continue;
const { name, arguments: argsText } = toolCall.function;
const args = JSON.parse(argsText);
const result = await executeTool(name, args);
// 工具结果使用 role: 'tool',并回传 tool_call_id
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify(result)
});
}
// 再次请求模型,得到最终回复
const finalResponse = await chatCompletion(messages);这段代码展示了两件事:工具执行后要向消息列表追加 role: 'tool' 的消息;第二次请求模型时,模型能够看到工具返回结果,并据此生成最终回复。
工具结果消息必须携带 tool_call_id,且 content 通常是序列化后的 JSON 字符串。前端在展示工具结果时不应把 content 当作纯文本直接拼入界面。
Human-in-the-loop 是指工具执行前需要用户确认。常见交互是:模型返回工具调用后,前端不直接执行,而是展示工具名称、参数和预期影响,等待用户点击「允许」。需要说明的是,这种确认状态不是 LLM API 的标准字段,而是 Agent Runtime 与前端之间的协议字段。不同实现的字段名不同。前端只要把这种状态视为任务的一个等待节点,按契约渲染确认界面即可。
工作原理:流式输出与事件协议
LLM 生成结果需要流式传输,因为文本是逐 token 产生的。常见通道有三种:
- SSE(Server-Sent Events,服务器发送事件):服务端单向推送事件,协议简单,媒体类型为
text/event-stream[4]。 - WebSocket:全双工,适合客户端需要持续上报事件的场景。
- fetch ReadableStream:基于
Response.body,可以使用 POST 发送较长的消息体,并逐块读取。
Agent 请求通常需要发送较长的消息上下文,适合使用 POST,因此下面使用 fetch ReadableStream。
假设服务端按行返回 SSE 风格的数据:
event: message
data: {"id": "m1", "type": "token", "content": "你"}
event: message
data: {"id": "m1", "type": "token", "content": "好"}使用 fetch 读取:
js
async function readAgentStream(url, body) {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
if (!response.body) {
throw new Error('ReadableStream not supported');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex;
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (!payload) continue;
const event = JSON.parse(payload);
handleEvent(event);
}
}
}这里有几个必须处理的问题:
decoder.decode(value, { stream: true })会保留不完整的多字节 UTF-8 字符。如果数据块在字符边界处被截断,TextDecoder会把未完成序列保存起来,等下一块到达后拼出完整字符。不使用stream: true时,半个字符会被替换为�。- 网络数据块不是按行到达的。一行数据可能被拆到多个
value中,也可能一个value包含多行。因此需要维护buffer,按换行符切分。如果直接把每个value当作完整 JSON 解析,就会遇到 JSON 解析错误。 - 这里假设一个事件对应单行 JSON。如果服务端使用标准 SSE 的多行
data块,解析器需要按空行聚合事件后再解析。
注意:部分模型服务端不支持流式与 function calling 同时开启。使用工具调用时,可能需要在请求中把 stream 设为 false,等待工具调用完成后,再把工具结果一次性发回模型 [2]。
基本用法:组件架构与状态管理
组件架构
Agent Interface 的组件架构可以分成四类:
- 消息流:渲染消息列表,包括助手消息、用户消息和工具调用记录。
- 任务面板:显示当前任务状态、执行进度和完成时间。
- 计划组件:把模型给出的执行计划以列表形式展示,并标记步骤完成情况。
- 工件组件:展示模型生成的文件、代码、图表等长期产物。Artifacts 模式就是把工作产物从消息流中剥离出来,提供独立的查看与编辑空间。
组件树结构如下,组件名仅为示例:
jsx
<AgentWorkspace>
<ConversationStream />
<ToolCallList />
<TaskPanel />
<ArtifactsPanel />
</AgentWorkspace>ToolCallList 中的每一项需要展示工具名、参数、执行状态和执行结果:
jsx
function ToolCallItem({ call }) {
return (
<div className="tool-call">
<span>{call.status}</span>
<code>{call.name}</code>
<pre>{call.arguments}</pre>
{call.result && <pre>{call.result}</pre>}
</div>
);
}所有文本字段都应使用 pre、textContent 或框架的转义插值渲染,不要直接把工具结果插入 HTML。
状态管理
Agent Interface 需要同时维护三类状态:
- 会话状态:消息列表、模型 ID、上下文 token 数等。
- 任务状态:当前任务对象及其步骤。
- Agent 执行状态:整体执行状态,如
streaming、waiting_confirmation、idle。
状态模型示例:
js
const state = {
session: {
id: 's_1',
messages: []
},
task: {
id: 't_1',
status: 'running',
steps: []
},
agent: {
status: 'streaming',
lastEventAt: null
}
};状态管理遵循单向数据流:事件驱动 reducer 更新状态,组件只从状态树读取数据。
js
function agentReducer(state, action) {
switch (action.type) {
case 'message/token':
return {
...state,
session: {
...state.session,
messages: appendToken(state.session.messages, action.payload)
}
};
case 'task/status':
return {
...state,
task: {
...state.task,
status: action.payload.status
}
};
default:
return state;
}
}appendToken 是一个纯函数,它返回新的消息数组,而不修改原数组。这样消息流、任务面板和工具列表都从同一个状态树派生,避免多个本地状态互相覆盖。
API:前端与 Agent Runtime 的契约设计
前端需要与 Agent Runtime 约定一套接口。Agent 产品通常以流式接口为主。
请求示例:
POST /api/agent/run
Content-Type: application/json
{
"session_id": "s_1",
"messages": [...],
"tools": ["get_weather", "search_docs"],
"stream": true,
"enable_confirmation": true
}这里的 tools 数组只是简化写法。实际项目中,tools 可能携带完整工具描述或工具 schema,具体格式由 Agent Runtime 决定。
服务端返回 text/event-stream。事件序列示例:
event: message
data: {"type": "message.created", "message": {"id": "m1", "role": "assistant"}}
event: message
data: {"type": "message.token", "id": "m1", "content": "根据"}
event: message
data: {"type": "tool_call.created", "id": "t1", "name": "get_weather", "arguments": "{}"}
event: message
data: {"type": "task.waiting_confirmation", "task_id": "task1", "tool_call_id": "t1"}
event: message
data: {"type": "tool_call.result", "id": "t1", "result": "{\"temp\": 20}"}
event: message
data: {"type": "task.status", "task_id": "task1", "status": "completed"}上面的 tool_call.created、waiting_confirmation、tool_call.result 是自定义事件名,不是通用标准。实际项目需要与 Runtime 团队共同定义,并维护一份 TypeScript 联合类型。
error 事件需要包含错误码和可读信息:
json
{
"type": "error",
"code": "TOOL_EXECUTION_FAILED",
"message": "get_weather 执行失败"
}协议设计还有两个注意点:
- 事件字段名要保持一致。比如工具结果显示字段统一为
result,不要有时用result,有时用output。 - 前端解析器对未知事件应做忽略处理,以便协议向前兼容。
流式渲染性能
Agent Interface 的消息列表可能很长。虚拟列表的思路是只渲染视口内的消息,滚动时动态替换。固定行高时,可以通过计算切片:
js
function getVisibleMessages(messages, scrollTop, viewportHeight, rowHeight) {
const start = Math.floor(scrollTop / rowHeight);
const end = Math.ceil((scrollTop + viewportHeight) / rowHeight);
return messages
.slice(start, end)
.map((msg, index) => ({
...msg,
top: (start + index) * rowHeight
}));
}这个函数把可视区域换算成消息索引区间,并为每条可见消息计算绝对定位的 top 值。示例假设所有消息行高相同。
流式输出阶段,每收到一个 token 就触发 setState 会导致频繁渲染。常见做法是合并更新:用 requestAnimationFrame 合并一帧内到达的多个 token。
js
let tokens = [];
let scheduled = false;
function handleToken(token) {
tokens.push(token);
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
flushTokens();
scheduled = false;
});
}
function flushTokens() {
const text = tokens.join('');
tokens = [];
appendToMessage(text);
}requestAnimationFrame 的回调在一帧内最多执行一次,因此在 60Hz 屏幕下,连续的 token 会被合并为每秒最多 60 次渲染。
注意:正在流式的消息长度会动态变化。虚拟列表如果每行高度固定,流式消息可能超出估算高度。推荐做法是对正在流式的消息单独处理,不参与虚拟化,或动态测量高度。
取消、重试与竞态处理
用户发送消息后可能想停止生成。浏览器取消 fetch 流的标准方式是 AbortController:
js
const controller = new AbortController();
async function startStream() {
const response = await fetch('/api/agent/run', {
method: 'POST',
signal: controller.signal
// ...
});
}
// 用户点击停止
controller.abort();取消后,前端应把当前消息状态标记为 interrupted,并保留已生成的内容。fetch 的 read() 会抛出 AbortError,因此调用处需要捕获并区分「主动取消」和「网络错误」。
重试时需要确定边界:如果流已经输出了一部分内容,重试是整个任务重新开始,还是从最后一个稳定状态继续,取决于后端是否支持断点续跑。前端可以控制的是对未开始或已失败的任务重新发起请求。
竞态问题典型发生在连续发送多条消息时。旧请求的流式数据可能在新请求之后到达。处理方式是为每次请求维护一个 AbortController,发送新请求时取消旧请求:
js
let currentController = null;
async function sendMessage(text) {
currentController?.abort();
const controller = new AbortController();
currentController = controller;
// 后续读取只接受 controller.signal
}每次发送新消息都会取消上一次未完成的请求,避免旧事件覆盖新消息的状态。
安全边界:提示注入与不受信内容渲染
LLM 输出的文本、工具返回的数据都可能来自不受信的来源。前端渲染必须避免使用 innerHTML 拼接模型内容。即使渲染 Markdown,也需要经过白名单化的 Markdown 渲染器处理,并在渲染前转义 HTML。框架自带的 {} 插值默认转义,是可靠的方式。
例如,工具返回一段包含 <img src=x onerror=...> 的内容。如果前端用 innerHTML 插入文档,就会执行注入脚本。正确做法:
js
const resultEl = document.querySelector('#tool-result');
resultEl.textContent = toolResult;textContent 会把内容当作纯文本处理,不经过 HTML 解析,因此不会创建 img 元素,也不会触发 onerror。
提示注入还可能通过消息内容诱导模型执行危险工具。前端无法从根本上阻止这种情况,但可以:
- 在界面上明示工具调用参数,让用户确认后再执行高权限操作。
- 对工具结果显示做长度截断,避免不受信内容淹没有界面。
- 权限校验由后端完成,前端提供交互可见性,但不是安全边界。
可观测性、测试与调试
调试流式输出需要能够追踪事件流。可以提供一个事件订阅器:
js
const listeners = new Set();
export function onAgentEvent(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
function handleEvent(event) {
for (const fn of listeners) {
fn(event);
}
}事件订阅器让日志面板、状态记录和调试工具都能观察到同一份事件流。
测试至少覆盖三层:
- 解析层:用固定的事件流文本测试流式解析,保证分片不乱。
- 状态层:给定事件序列,断言状态机迁移正确。
- 组件层:mock fetch 返回
ReadableStream,模拟真实流式发送。
模拟 fetch 流:
js
function mockAgentStream(chunks) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
chunks.forEach(chunk => controller.enqueue(encoder.encode(chunk)));
controller.close();
}
});
return Promise.resolve(new Response(stream));
}这个测试可以直接在支持全局 fetch 的 Node.js 环境中运行,也可以配合浏览器的 mock Service Worker。
应用:MCP、Artifacts 模式与 AI 原生前端
MCP(Model Context Protocol)是连接 LLM 与外部工具的开放协议 [5]。其作用是让 Agent Runtime 动态获取工具列表和工具 schema,而不是把工具硬编码到前端。对前端的影响是:工具列表可能来自远程,前端需要渲染动态工具选项,并在工具调用时回传 tool_call_id 与结果。
Artifacts 模式的核心思想是:把工件的展示与对话分离。消息流保留对话痕迹,代码、文件等内容在独立面板中渲染。这个模式直接改变组件架构,前端需要一个工件容器来管理文件类型、编辑状态和版本。
AI 原生前端的发展方向是:把 Agent 的执行过程视为一等数据单元。任务、工具调用、确认请求、工件和消息一样,都是 UI 的基础数据单元。设计状态模型时,可以从这里出发,而不是从「聊天」出发。
小结与下一步衔接
本文围绕 Agent Interface 的前端架构,覆盖以下要点:
- Agent Interface 的交互对象是「任务执行」,不是「对话」。
- 前端需要同时处理消息、任务状态、工具调用和工件四类数据。
- 流式协议建议使用 fetch ReadableStream 按行解析,正确处理文本解码和分片。
- 状态管理应遵循单向数据流,使界面行为可预测。
- 取消、重试、竞态处理需要围绕
AbortController和请求序号展开。 - 安全渲染是 AI 产品前端的必选项,不要用
innerHTML直接插入模型内容。 - 可观测性和测试是 Agent 前端工程化的支撑。
下一步可以围绕一个具体的 Agent Runtime API,封装前端 SDK,把事件解析、状态管理和组件渲染串联起来。
