解决问题:Agent应用开发和自定义渲染之间的关系
适合人群:'传统开发' 转 'Agent开发'过程中,对数据和UI渲染之间关系有困惑的人
心智模型:
- 理解LLM的定位和功能
- 理解客户端和服务端之间的协议定位
- 数据产生和渲染处理
我理解的 Agent 应用开发的核心在于:借助 LLM(大语言模型) 去驱动工程。
所以关键还是要借助 LLM 去驱动这个渲染关系。
如何做呢?带着心智模型,听我道来:
1. 理解LLM的定位和功能
理解,推理和生成
LLM 能做一件什么事呢?
最简单的例子就是:llm.invoke('你好'),然后它会回复一句:
你好呀,请问有什么能够帮助你?
这个过程,它会做:
- 理解
- 推理
- 生成
现在我们知道 LLM 可以理解、推理和生成自然语言文本。
Function Call
LLM 本质是文本续写。工具调用并不是模型真的"执行"了函数,而是模型在训练后学会了在特定格式下输出一段结构化文本(JSON),表达对外部工具的调用意图。
在模型被训练出来之前,业界靠 Prompt 工程 :让模型输出 特定格式,再用正则等方法去抠。能用,但很脆------格式动不动就跑偏、参数瞎编。(之前实习过程中就使用过 Guidance 做过类似的约束性Prompt工程)
现在让 LLM 做约束性的回答变得简单了,因为很多 LLM 厂商训练过的模型大部分都可以支持这种能力,例如DeepSeek:api-docs.deepseek.com/zh-cn/guide...
现在我们知道 LLM 通过 Function Call 实现了从"单纯的文本上的理解/推理/生成",到具备可以去描述我们组织的一些 Tools(我们写的一些方法定义)。
2. 理解客户端和服务端之间的协议定位
SSE
SSE(Server‑Sent Events,服务器发送事件)是一种基于标准 HTTP 的单向实时推送技术,由 W3C 标准化,允许服务器通过
text/event-stream长连接持续向客户端发送数据,常用于实时通知、行情推送和 AI 流式输出等场景。
SSE 只是为流式内容提供了一套标准事件格式和消费约定:
vbnet
event: progress ← 事件类型
id: 12 ← 事件标识,可用于重连游标
retry: 3000 ← 重连等待时间,毫秒
data: {"percent":60} ← 事件数据
← 空行结束一个事件
对应的Accept头部:text/event-stream,也可以不使用它,自己定义格式消费实现流式传输。
因为Agent应用天然是"服务器发送事件",而且加上LLM输出是一件耗时的事情,所以SSE协议非常合适做这一类应用的协议。
浏览器有一个实现方式EventSource,但事实上,很少会采用它去做这件事,从MDN的示例上来看:developer.mozilla.org/zh-CN/docs/... 会发现问题:
new EventSource(url, configuration);只针对资源路径,无法带着内容。那我想说一句:"你好",可怎么带着?- 在介绍中说的很清楚:
EventSource实例会对 HTTP 服务器开启一个持久化的连接,它是一个严格单向的协议。好家伙,严格单向,那我们通常的使用习惯是:我输入了一句话,然后等待服务器不断输出这样的形式。而这缺了一步。该怎么做呢?
通常做法是使用HTTP请求作为第一次请求,方法使用POST,并且在Http Accept头部指定:text/event-stream,这样又能带着消息,文件,图片等内容。服务器又明白通过text/event-stream的方式去处理。
javascript
const response = await fetch("/api/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify({ message: "你好" }),
});
// 后续从 response.body 逐步读取字节,
// 经过 UTF-8 解码和 SSE 解析,再交给界面。
这里有一个开源项目可以学习这种做法:github.com/Azure/fetch...
javascript
import { fetchEventSource } from "@microsoft/fetch-event-source";
async function sendMessage(message, render) {
const controller = new AbortController();
let answer = "";
let completed = false;
await fetchEventSource("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
signal: controller.signal,
// 此例不因页面切到后台而主动关闭流。
openWhenHidden: true,
async onopen(response) {
const type = response.headers.get("content-type") ?? "";
if (!response.ok || !type.startsWith("text/event-stream")) {
throw new Error(`无法开始接收:HTTP ${response.status}`);
}
},
onmessage(event) {
const data = JSON.parse(event.data);
if (event.event === "delta") {
answer += data.text;
render(answer);
}
if (event.event === "done") {
completed = true;
controller.abort();
}
},
onclose() {
if (!completed) {
throw new Error("响应提前中断");
}
},
onerror(error) {
// 本例禁止自动重发 POST,避免重新触发一次生成。
throw error;
},
});
}
await sendMessage("帮我分析这份报告", (text) => {
document.querySelector("#answer").textContent = text;
});
这里以SSE为例学习两端协议的思想。text/event-stream是一种可学习的方式,按照对这种格式的理解,我们也可以自定义开发中的协议。
css
Content-Type: application/x-ndjson
{"type":"progress","percent":20}
{"type":"progress","percent":60}
{"type":"completed","result":"分析完成"}
3. 数据产生和渲染处理
要让后端或模型驱动前端组件,首先需要让它们知道:有哪些组件、分别适合什么场景、接收什么参数,以及用户操作后会产生什么事件。
以 React 中一个简化的审批组件为例:
javascript
interface ApprovalProps {
/** 审批请求的唯一标识 */
requestId: string
/** 需要用户确认的操作 */
title: string
/** 操作可能产生的影响 */
impact: string
/** 用户作出审批决定 */
onResolve: (decision: 'approved' | 'rejected') => void
}
export function Approval({
title,
impact,
onResolve,
}: ApprovalProps) {
return (
<section>
<h3>{title}</h3>
<p>{impact}</p>
<button onClick={() => onResolve('approved')}>
批准
</button>
<button onClick={() => onResolve('rejected')}>
拒绝
</button>
</section>
)
}
这段代码已经定义了数据结构和交互行为。但仅凭 title: string,模型无法知道这个组件适合什么场景;仅凭 onResolve,后端也不知道应该接收什么事件。
因此,还需要在组件旁边声明用途和动作映射。下面的 approvalMeta 是本方案约定的元信息格式:
javascript
export const approvalMeta = {
name: 'Approval',
description: '展示一项操作及其影响,请求用户批准或拒绝。',
useWhen: ['业务流程需要人工确认后才能继续'],
avoidWhen: ['操作已经完成,只需要展示结果'],
events: {
onResolve: {
type: 'approval.resolve',
targetFrom: 'requestId',
payloadFromArgs: ['decision'],
},
},
} as const
这里的映射表示:调用 onResolve(decision) 时,以 requestId 标识审批对象,将参数放入 payload.decision,向外发出 approval.resolve 事件。
开发者维护组件类型、说明和动作映射,构建工具负责将它们整理成统一的组件契约。
构建时,工具读取 ApprovalProps 的类型与字段注释,再合并 approvalMeta,生成类似下面的机器可读描述:
json
{
"name": "Approval",
"description": "展示一项操作及其影响,请求用户批准或拒绝。",
"useWhen": ["业务流程需要人工确认后才能继续"],
"avoidWhen": ["操作已经完成,只需要展示结果"],
"propsSchema": {
"type": "object",
"properties": {
"requestId": {
"type": "string",
"description": "审批请求的唯一标识"
},
"title": {
"type": "string",
"description": "需要用户确认的操作"
},
"impact": {
"type": "string",
"description": "操作可能产生的影响"
}
},
"required": ["requestId", "title", "impact"],
"additionalProperties": false
},
"events": {
"onResolve": {
"type": "approval.resolve",
"targetFrom": "requestId",
"payloadFromArgs": ["decision"],
"payloadSchema": {
"type": "object",
"properties": {
"decision": {
"enum": ["approved", "rejected"]
}
},
"required": ["decision"],
"additionalProperties": false
}
}
}
}
onResolve 是函数,不能直接作为 JSON 参数传输,所以它被表达为事件契约。前端在渲染时负责绑定实际回调。
这个提取过程需要额外的构建工具实现,普通 TypeScript 编译不会自动生成上述描述。工具也需要明确支持的类型范围:字符串、对象、数组等可以转换;ReactNode、渲染函数等类型则需要适配或排除。
多个组件的契约汇总后,就形成了组件目录(Catalog)。目录可以随构建产物发布,也可以由独立服务提供给后端和模型。描述文字仍由开发者编写,构建工具自动完成的是提取、转换和汇总。
有了目录,业务数据就可以被转换成具体的渲染指令。例如,后端产生一笔待审批请求,UI 决策层选择 Approval,输出:
json
{
"id": "approval-view-001",
"component": "Approval",
"props": {
"requestId": "request-001",
"title": "是否发布本次更新?",
"impact": "发布后,所有用户将使用新版本。"
}
}
业务数据由后端提供。UI 决策层可以通过确定规则选择组件,也可以让模型根据目录中的说明选择组件。对于"待审批请求展示审批卡片"这种固定关系,直接使用规则即可。
渲染指令通过 SSE 等方式传到前端后,前端依次完成:
- 根据
component查找本地注册的组件。 - 使用对应的
propsSchema校验参数。 - 根据事件契约绑定回调,将参数传入实际组件。
因此,"component": "Approval" 最终对应的是前端开发者编写的 <Approval />。组件的布局、颜色和交互样式仍由本地实现决定。未知组件或非法参数应进入明确的错误或降级路径。
当用户点击"批准"时,绑定的回调再将操作转换为结构化事件:
json
{
"type": "approval.resolve",
"targetId": "request-001",
"payload": {
"decision": "approved"
}
}
后端接收事件,检查当前审批状态与用户权限,执行业务处理,再将更新后的状态发送给前端。这样,组件声明、渲染指令和用户操作就连接成了完整的数据流;同一份组件目录同时服务于模型选型、前端校验和事件绑定。
以上就是作者对于Agent应用开发和渲染之间关系的思考。欢迎讨论。