大多数 AI 聊天 demo,一开始都长得差不多:一个输入框,一个后端接口,一段流式输出,前端把 token 一点点打印到页面上。
这个阶段看起来很顺,甚至会让人产生一种错觉:AI 聊天系统最难的事情,无非就是把模型输出流式渲染出来。
但你只要再往前走一步,比如给模型加一个联网搜索工具、一个邮件发送工具,或者一个知识库检索能力,问题马上就变了。
因为这时候前端面对的就不再只是"文本"。它还要面对:
- 工具什么时候被调用
- 工具参数有没有生成完成
- 工具执行成功还是失败
- 工具输出应该渲染成普通文本,还是渲染成结构化卡片
- 最终回答是模型直接生成的,还是基于工具结果二次生成的
- 用户点击停止后,后端能不能把模型和工具一起中断掉
也就是说,真正难的地方不在 LLM API,而在 UI 协议。
我这次看的是一个很小、但很有代表性的项目:agui-backend + agui-frontend。它的代码量并不大,README 甚至还是模板级别,但源码里已经出现了一个很值得复用的工程思路:
一个真正可用的 AI 聊天系统,关键不是"把模型回复流式打印出来",而是"让文本、工具输入、工具输出、错误和取消都成为同一条 UI 消息协议的一部分"。
这篇文章我就围绕这个主结论展开,拆掉它背后的前后端实现思路、工程取舍和演进边界。
先给结论:这套实现真正值得学的,不是聊天框,而是"统一事件流"
先把结论说得更具体一点。
这套项目的核心价值,不是它用了 NestJS、React、LangChain 或 AI SDK 这些词本身,而是它把下面几件事放进了一条统一链路里:
- 前端输入的是
message.parts - 后端输出的不是单纯文本,而是
UIMessageChunk - 模型流式文本会被拆成
text-start、text-delta、text-end - 工具调用会被拆成
tool-input-available、tool-output-available、tool-output-error - 前端不是按"整条 assistant 文本"渲染,而是按
part类型分别渲染
这意味着:
- 文本是 UI 的一等公民
- 工具过程也是 UI 的一等公民
- 错误与中断不是补丁,而是协议里的正式成员
这套思路的工程收益非常大。
如果你走的是"后端把所有过程都揉成一大段字符串,前端只管显示 markdown"这条路,那么功能一多,前端一定越来越脆:工具状态看不清、错误不好处理、体验不稳定、组件也没法复用。
而如果你从一开始就把聊天、工具、状态都做成统一事件流,那前端就可以保持很薄,后端也更容易演进成真正的 Agent UI。
这就是我认为这篇源码最值得拿出来讲的点。
一、先看项目真实边界:它不是一个"大而全 Agent 平台",而是一条做对了的最小链路
先强调一件事:这不是一个通用 Agent 平台。
从当前源码看,它的真实边界非常明确。
后端边界
后端主要是一个 NestJS 单体服务,真实 AI 入口几乎只有一个:POST /ai/chat。
入口很薄,src/main.ts 只负责启动应用、开启 CORS、监听端口:
ts
async function bootstrap() {
const app = await NestFactory.create(AppModule)
app.enableCors({
origin: '*',
credentials: true,
})
await app.listen(process.env.PORT ?? 3000)
}
AI 能力真正落在 AiModule、AiController 和 AiService 上。
前端边界
前端是一个单页 React 应用,没有路由、没有全局 store,也没有复杂的业务壳。它把核心关注点收敛得很清楚:
- 接消息
- 发消息
- 显示文本流
- 显示工具调用状态
- 显示工具结果
- 允许用户停止生成
这反而是好事。
因为很多人做 AI 前端,一上来先做 UI 框架、主题系统、状态管理、插件面板,最后真正决定可用性的协议层却没有设计好。这个项目反过来:壳很薄,但链路很完整。
当前实际能力
基于源码,当前真正接通的工具能力只有一个:web_search。
后端在 src/ai/ai.module.ts 里注册了 WEB_SEARCH_TOOL,调用 Bocha Web Search API;前端在 components/ToolPanels.tsx 里对 web_search 做了专门展示。
同时,前端还预留了 send_mail 的 UI 面板,但当前后端并没有对应的 send_mail 工具实现。这说明什么?说明这个项目已经在往"多工具可视化"方向走,但目前仍处在一个真实工具 + 一个预留工具的阶段。
这也是工程里很常见的状态:协议先跑通,能力再慢慢补齐。
一个必须指出的事实:README 几乎不可信
这两个仓库的 README 目前都还是脚手架模板。
- 后端 README 还是标准 Nest starter
- 前端 README 还是标准 Vite React TypeScript 模板
所以这篇文章里我不会依据 README 组织结构,而是完全以当前源码实现为准。这也正是做 AI 工程复盘时最重要的习惯:
文档可以过期,源码才是当前系统最诚实的描述。
二、为什么"纯文本流式聊天"一遇到工具调用就不够了
要理解这套设计为什么重要,先得理解"纯文本流式聊天"到底缺了什么。
假设你做一个联网问答助手。用户问:
北京今天的天气怎么样?
最朴素的实现,是后端在 Prompt 里告诉模型"如果需要最新信息请自行处理",或者干脆由后端先搜索再把搜索结果拼进 prompt,最后把模型回答当作一整段 markdown 流式输出给前端。
这个方案不是不能跑,但有四个典型问题。
1. 用户看不见系统过程
用户只看到"生成中",却不知道系统到底在干什么。
它是在思考? 在联网? 联网失败了? 已经拿到结果但还在总结?
纯文本流只能告诉你"有字在冒出来",不能告诉你"系统此刻处于哪个阶段"。
2. 工具错误很难优雅展示
如果搜索失败了,你通常只能把错误揉成一行普通文本,比如:
- 搜索失败,请稍后重试
- 网络异常
- 工具调用超时
这会让错误和正常回答混在一起,既不好看,也不利于前端做专门交互。
3. 工具结果无法结构化利用
比如搜索结果本来有标题、URL、摘要、站点名称、发布时间。如果后端把它们直接揉成一大段文本喂给模型,再把模型回答回给前端,那前端基本丢掉了做结果卡片、引用区、展开摘要、点击来源的机会。
4. 多工具场景会快速失控
当系统只有一个 web_search,你还能硬撑。
一旦出现:
- 搜索工具
- 知识库检索工具
- 发邮件工具
- 创建工单工具
- 读文件工具
如果仍然靠"最终文本里顺带描述过程",那前端一定会越来越像日志窗口,而不是产品界面。
所以我更推荐的默认思路不是"把工具过程藏起来",而是:
把工具过程显式暴露给 UI,但不要让 UI 直接耦合后端内部实现。
这套 agui 的做法,本质上就是把中间状态标准化成消息事件,再交给前端渲染层解释。
三、建立基础认知:这套系统里,模型、工具、协议、UI 分别负责什么
很多人看这类代码时,会把不同层次混在一起。这里先把职责拆开。
1. 模型层:负责生成,不负责展示
后端模型实例来自 ChatOpenAI,在 AiModule 里统一创建:
ts
return new ChatOpenAI({
model: configService.getOrThrow<string>('MODEL_NAME'),
apiKey: configService.getOrThrow<string>('OPENAI_API_KEY'),
configuration: {
baseURL: configService.get<string>('OPENAI_BASE_URL'),
},
})
它的职责是:
- 接收消息历史
- 根据系统提示和用户输入生成回答
- 在需要的时候发起 tool call
它不关心前端用 React 还是 Vue,也不关心工具结果是卡片还是表格。
2. 工具层:负责外部能力,不负责消息协议
当前后端把联网搜索封成了一个 LangChain tool:
- 参数用
zod定义 - 真实外呼 Bocha Web Search API
- 返回"可继续喂给模型"的搜索结果文本
工具层的职责是:
- 描述工具名和参数 schema
- 执行外部能力
- 产出结果或错误
它不负责前端渲染,也不应该直接操作 HTTP 响应。
3. 协议层:负责把"模型行为"和"工具行为"翻译成 UI 能理解的事件
这层才是整个系统的关键。
后端 AiService 不是简单地 await model.invoke() 然后 res.write(),而是用 createUIMessageStream 组织一个 UI 事件流。
这意味着它要做两件事:
- 把模型 token 流变成文本事件
- 把工具调用过程变成工具事件
所以它本质上是一个桥接层。
4. 前端 UI 层:负责按 part 渲染,不负责猜测后端过程
前端的核心不是"拿到一整段 markdown 然后直接显示",而是:
- 按消息 part 渲染
text用Streamdown处理tool用ToolPanels处理- 不同工具、不同状态走不同组件分支
这很重要。因为前端不是靠字符串猜流程,而是靠协议拿到流程。
四、系统架构:这条链路到底是怎么串起来的
先看整体架构图。
如果把它翻译成一句工程语言,就是:
React 前端负责消费
UIMessage,Nest 后端负责生产UIMessageChunk,LangChain 负责模型与工具调用,AI SDK 负责把这套事件流从服务端送到前端。
注意这里的边界非常清晰:
- 模型是否调用工具:由模型与 tool binding 决定
- 工具怎么执行:由后端 tool provider 决定
- 工具过程怎么呈现:由消息事件协议决定
- 最终长什么样:由前端组件决定
这就是一个很干净的分层。
五、请求时序:一次对话是怎么从输入走到最终 UI 的
再看一次典型时序。
这张图其实已经解释了为什么这套系统比"纯文本流式输出"更适合工具型 AI 产品。
用户不仅能看到最终回答,还能看到:
- 工具是否触发
- 工具输入是什么
- 工具有没有返回结果
- 模型是否基于工具结果继续生成
对一个"问答助手"来说,这提升的是透明度;对一个"Agent UI"来说,这提升的是可调试性和可信度。
六、后端实现拆解:真正的核心在 AiService.stream()
后端最值得读的文件,是 src/ai/ai.service.ts。
1. Controller 做得很薄,这是对的
先看控制器:
ts
@Post('chat')
postChat(
@Body() body: { messages?: AiChatMessage[] },
@Req() req: Request,
@Res({ passthrough: false }) res: Response,
): void {
if (!body?.messages || !Array.isArray(body.messages)) {
throw new BadRequestException('Invalid JSON')
}
const abortController = new AbortController()
req.once('close', () => abortController.abort())
const stream = this.aiService.stream(body.messages, abortController.signal)
pipeUIMessageStreamToResponse({
response: res,
stream,
})
}
这段代码做了四件事:
- 校验输入是否包含
messages - 为本次请求创建
AbortController - 监听连接关闭,把中断信号往下游传
- 用
pipeUIMessageStreamToResponse直接把 UI 事件流写回响应
我很认可这种写法,因为它让 Controller 保持在一个正确的位置上:
- 不碰模型细节
- 不碰工具细节
- 不拼字符串
- 只负责 HTTP 协议与取消传播
很多项目喜欢在 Controller 里一把梭,把 prompt、tool、格式化、响应写出都塞进去。那样一开始快,但后面几乎没法维护。
2. 为什么 AbortController 很关键
流式聊天系统一个经常被忽视的问题是:用户停止了,但后端还在跑。
这不只是体验问题,也是成本问题。
这里的做法是,前端点"停止"或连接断开时,req.close 触发 abortController.abort(),然后把 signal 一路传给 AiService,再传给模型流和工具调用。
这意味着:
- 用户界面上的"停止"不是假的
- 模型生成和工具调用有机会被真正取消
- 你不会在用户已经离开页面后,继续白白消耗模型 token 和外部 API 次数
对于一个 AI 服务来说,这属于非常应该有,但很多 demo 不做的能力。
3. 真正的 Agent loop:模型 -> 工具 -> 模型
核心代码在这里:
ts
return createUIMessageStream({
execute: async ({ writer }) => {
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
const response = await this.streamRound(history, writer, signal)
history.push(response)
if (!response.tool_calls?.length) {
return
}
for (const toolCall of response.tool_calls) {
const toolCallId = toolCall.id ?? generateId()
writer.write({
type: 'tool-input-available',
toolCallId,
toolName: toolCall.name,
input: toolCall.args,
})
const toolMessage = await this.invokeTool(
{ ...toolCall, id: toolCallId },
signal,
)
writer.write({
type: 'tool-output-available',
toolCallId,
output: this.stringifyContent(toolMessage.content),
})
history.push(toolMessage)
}
}
await this.streamRound(history, writer, signal)
},
})
这段代码是整套系统的灵魂。
它体现了三个很关键的工程判断。
判断一:先让模型决定要不要调用工具,而不是后端写死流程
后端没有在请求一进来时就先查搜索引擎,也没有提前做规则判断,而是先把对话历史交给模型,允许模型决定是否触发 tool_calls。
这比"所有问题都先搜一下"更合理,因为:
- 不是所有问题都需要联网
- 不需要联网的问题,应该直接回答
- 需要联网的问题,再走工具链
这是典型的"把决策交给模型,把执行交给工具"的分工。
判断二:工具调用过程要先发给前端,再去执行
这里不是工具跑完了才告诉前端"我用过工具",而是在执行前就发出:
tool-input-available
执行成功后再发:
tool-output-available
如果失败,则发:
tool-output-error
这一步特别关键。因为它让前端可以展示"工具正在被调用"和"工具已经返回结果"这两个不同阶段,而不是只有最终态。
这也是为什么前端可以做出更像产品而不是日志面板的 UI。
判断三:工具循环必须有限制
代码里有两个限制:
parallel_tool_calls: falseMAX_TOOL_ROUNDS = 3
我认为这是对的,而且是默认推荐。
原因很简单:这个项目当前是一个极简流式问答后端,不是一个复杂任务编排器。既然目标是稳定、透明、前后端对齐,那就不应该一上来就支持并行多工具和无限递归调用。
对大多数业务来说,先做串行、先做有限轮次、先让 UI 看得清楚,比追求"理论上更强"更重要。
4. streamRound() 做的是协议翻译,不是业务拼接
再看 streamRound():
ts
const stream = await this.chatModelWithTools.stream(history, { signal })
let response: AIMessageChunk<Record<string, unknown>> | undefined
let textPartId: string | undefined
for await (const chunk of stream) {
response = response ? response.concat(chunk) : chunk
const delta = this.extractChunkText(chunk)
if (!delta) continue
if (!textPartId) {
textPartId = generateId()
writer.write({ type: 'text-start', id: textPartId })
}
writer.write({
type: 'text-delta',
id: textPartId,
delta,
})
}
if (textPartId) {
writer.write({ type: 'text-end', id: textPartId })
}
这段逻辑的重点不是"流式输出文本"本身,而是:
- 它把模型 chunk 累积成完整
AIMessage - 同时又把可展示的文本增量转成 UI 事件
换句话说,它同时服务两边:
- 对前端:输出可消费的流事件
- 对后续工具轮次:保留完整模型响应对象
这就是一个很典型的桥接层写法。
如果这里直接把 chunk 拼成字符串再返回,你很快就会发现:
- 前端失去事件粒度
- 后续 tool call 不好接
- 模型响应元信息不好保留
所以这不是"写得花",而是协议层本来就应该这么做。
七、工具层实现拆解:为什么搜索工具被封成 LangChain Tool,而不是普通函数
再看 AiModule 里的工具定义。
ts
const webSearchArgsSchema = z.object({
query: z.string().min(1).describe('搜索关键词,例如:公司年报、某个事件等'),
count: z.number().int().min(1).max(20).optional(),
})
return tool(
async ({ query, count }: { query: string; count?: number }) => {
const apiKey = configService.get<string>('BOCHA_API_KEY')
if (!apiKey) {
return 'Bocha Web Search 的 API Key 未配置(环境变量 BOCHA_API_KEY),请先在服务端配置后再重试。'
}
const response = await fetch('https://api.bochaai.com/v1/web-search', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
freshness: 'noLimit',
summary: true,
count: count ?? 10,
}),
})
// 省略错误处理...
},
{
name: 'web_search',
description: '使用 Bocha Web Search API 搜索互联网网页。',
schema: webSearchArgsSchema,
},
)
这里有三层值得讲。
1. 工具不是普通 helper,而是"可被模型理解的能力描述"
如果只是后端自己调用搜索接口,那写成普通函数就够了。
但这里它被注册成 tool,有:
namedescriptionschema
这代表它不只是给开发者看的,而是给模型看的。
模型知道:
- 这个工具叫什么
- 它适合干什么
- 它接收什么参数
这就使得"模型决定是否调用工具"成为可能。
2. 参数 schema 不只是校验,也是行为约束
query 必填、count 取值 1 到 20,这不是小事。
因为工具越自由,模型越容易生成不稳定参数。把 schema 收紧,其实是在给 Agent 行为加护栏。
我对工具参数的默认建议一直是:
- 参数越少越好
- 范围越明确越好
- 可解释性越强越好
这段代码基本符合这个原则。
3. 当前搜索结果返回"格式化文本",优点是快,代价是后续结构化能力受限
当前实现把 Bocha 返回的数据,格式化成这种文本:
text
引用: 1
标题: ...
URL: ...
摘要: ...
网站名称: ...
发布时间: ...
这么做的好处是:
- 模型可以直接消费
- 前后端不需要共享复杂 DTO
- demo 非常容易跑通
但代价也很明确:
- 前端还要自己再解析一次
- 工具结果难以做严格结构化约束
- 如果后端文本格式改了,前端解析就可能失效
所以我的判断是:
- 对 demo 和最小可用版本,这种写法是合理的
- 对要长期演进的产品,默认不建议把结构化结果只保留成文本
更稳妥的做法通常是:
- 一份结构化结果给前端 UI
- 一份摘要文本给模型继续推理
也就是把"给 UI 的数据"和"给模型的上下文"分开。
这套项目目前还没走到那一步,但作为下一阶段演进方向非常明确。
八、前端实现拆解:为什么 message.parts 比"一整条 markdown"更关键
再来看前端。
1. useChat + DefaultChatTransport 让前端保持很薄
src/App.tsx 里最关键的初始化如下:
tsx
const chatUrl = `${API_BASE}/ai/chat`
const transport = useMemo(
() =>
new DefaultChatTransport({
api: chatUrl,
}),
[chatUrl]
)
const { messages, sendMessage, status, stop, error, clearError } =
useChat<UIMessage>({ transport })
这段代码说明前端的整体策略是:
- 把会话传输交给 SDK
- 把会话状态交给 SDK
- 自己主要负责界面解释
这对大多数 AI 产品是很好的默认路线。
因为很多时候,你真正的业务价值不在"自己手写聊天状态机",而在"怎么把 Agent 过程变成可理解的 UI"。
2. 前端不是按 message.content 渲染,而是按 message.parts 渲染
这点非常关键:
tsx
{message.parts.map((part, index) => (
<MessagePart
key={`${message.id}-p-${index}`}
part={part}
textStreamActive={
part.type === 'text' &&
message.role === 'assistant' &&
message.id === lastAssistant?.id &&
index === lastTextPartIdx &&
busy
}
/>
))}
这个写法意味着,一条 assistant 消息可以同时包含:
- 文本 part
- 工具 part
这和传统聊天 UI 完全不同。
传统聊天 UI 往往假设:
- 一条消息 = 一段文本
但 Agent UI 里,更合理的抽象其实是:
- 一条消息 = 一组不同类型的片段
这会直接影响你的组件设计能力。
如果你坚持"一条消息就是一个字符串",那工具调用一定只能作为字符串里的描述存在。
而一旦你接受 parts 模型,工具卡片、等待态、错误态、富文本段落就都能自然共存。
3. 为什么只让"最后一段 assistant 文本"处于 streaming 态
前端还有一个细节做得很好:
它没有让所有文本都带动画,而是只把"最后一条 assistant 消息里的最后一个 text part"标成 streaming。
这解决了一个常见问题:
如果整条历史消息都被当作 streaming 渲染,页面会出现不必要的动画、反复重排,甚至 Markdown 解析也会显得不稳定。
所以这里的判断很务实:
- 流式只属于当前正在增长的那一段
- 历史文本应当视为稳定内容
这属于体验细节,但恰恰最体现工程成熟度。
九、为什么 Streamdown 很适合这个场景
再看文本渲染组件:
tsx
<Streamdown
mode="streaming"
isAnimating={isStreaming}
parseIncompleteMarkdown
shikiTheme={shikiTheme}
plugins={{ mermaid, code: codePlugin }}
className="chat-streamdown__inner"
>
{children}
</Streamdown>
这段代码背后的判断,我认为非常准确。
1. AI 文本不是普通 markdown,它经常"没写完"
模型流式输出时,你经常会遇到这些状态:
- 代码块还没闭合
- 列表还没结束
- Mermaid 图只输出了一半
- 表格还在继续生成
如果你用普通 Markdown 渲染器,有时页面会闪、会断、会暂时渲染失败。
parseIncompleteMarkdown 的意义就在这里:允许未闭合 markdown 也尽量可读。
这对流式 AI 界面是非常实用的。
2. 代码高亮和 Mermaid 不应该靠后处理补丁来拼
前端直接把 code 和 mermaid 插件挂进渲染器,而不是等文本生成完再去二次扫描,这使得整条链路保持一致。
换句话说:
- 渲染器知道这是一段流式内容
- 渲染器知道内容里可能有代码和图
- 渲染器从第一天起就按流式内容的真实形态去设计
这比"先简单显示文本,后面再打补丁支持代码块/流程图"更稳。
3. 但这里也有一个现实代价:前端包体积会很大
我实际跑了前端构建,构建是成功的,但产物里能看到大量与高亮、Mermaid 相关的 chunk,主包也不小。
这说明一个现实:
富文本、代码高亮、图表渲染这些体验能力,不是没有成本的。
所以默认建议是:
- 如果你的产品只是一个极简客服问答框,不一定要一开始就上 Mermaid 和全量高亮
- 如果你的产品目标是技术问答、Agent 工作台、研究助手,那这些能力是值得的
也就是说,它不是"越多越好",而是要看产品目标。
十、工具 UI 为什么要单独做面板,而不是混在回答正文里
ToolPanels.tsx 是前端另一个很值得讲的文件。
1. 搜索结果被单独渲染成卡片,而不是混成回答文本
前端对 web_search 的处理,大致是:
tsx
function parseWebSearchBlocks(text: WebSearchToolOutput): WebSearchResultItem[] {
const blocks = text.split(/\n\n+/).map(b => b.trim()).filter(Boolean)
const items: WebSearchResultItem[] = []
for (const block of blocks) {
const ref = block.match(/^引用:\s*(\d+)/m)?.[1] ?? ''
const title = block.match(/标题:\s*(.+)/)?.[1]?.trim() ?? ''
const url = block.match(/URL:\s*(\S+)/)?.[1]?.trim() ?? ''
const summary =
block.match(/摘要:\s*([\s\S]*?)(?=\n\s*网站名称:|$)/)?.[1]?.trim() ?? ''
if (title || url || summary || ref) {
items.push({ ref, title, url, summary })
}
}
return items
}
然后再渲染成结构化列表。
这说明它在产品层面已经有一个明确判断:
- 搜索结果不是普通聊天文本
- 搜索结果是一种"有来源、有链接、有摘要"的特殊内容
- 这种内容应该有专门的视觉形态
我很认同这一点。
因为对用户来说,**"我看到了系统查了什么"**本身就是建立信任的重要部分。
2. 工具的"进行中"状态和"完成态"必须分开
前端不是只处理 output-available,还处理:
output-error- 非
output-available的等待态 send_mail这种输入流阶段的半成品状态
这个设计非常接近真实 Agent UI 的需求。
一个工具调用并不只有结果,它至少有三类状态:
- 正在决定怎么调用
- 正在调用
- 已返回结果或已失败
如果 UI 只展示第 3 类状态,用户会觉得系统在"黑盒工作";如果第 1、2、3 类状态都能被看见,整个产品就会透明很多。
3. 这里暴露出一个很真实的演进痕迹:前端已经为 send_mail 预留了协议位
前端为 send_mail 做了比 web_search 更复杂的面板:
- 展示收件人
- 展示主题
- 展示正文生成中状态
- 展示 HTML 或文本内容
但当前后端没有对应的工具实现。
这并不是坏事。它恰恰说明这套协议抽象已经具备可扩展性:
- 今天是
web_search - 明天可以是
send_mail - 后天可以是
create_ticket - 只要消息 part 结构不变,前端就能按工具类型继续扩展
这也是我一直强调的:先把协议做稳,比先把工具数量做多更重要。
十一、方案对比:如果是我来做,这三个地方我会怎么选
接下来不只是解释代码,而是给出明确判断。
对比一:纯文本 SSE vs 结构化 UIMessage 事件流
方案 A:纯文本 SSE
优点:
- 上手快
- demo 简单
- 前后端实现量小
缺点:
- 工具状态不可视
- 错误难分层处理
- UI 只能猜过程
- 多工具后几乎不可维护
方案 B:结构化 UIMessage 事件流
优点:
- 文本与工具状态可以并存
- 前端组件化空间大
- 可调试性、透明度更高
- 更适合 Agent UI 演进
缺点:
- 设计成本更高
- 前后端需要统一 part/event 抽象
我的判断:
- 如果你只是做一个不带工具的最小聊天框,纯文本 SSE 足够
- 只要你的系统存在工具调用、检索、操作类能力,默认就应该走结构化 UI 事件流
这套 agui 的路径,我认为是对的。
对比二:后端先强制搜索,再喂模型 vs 让模型自己触发工具
方案 A:后端规则先搜
优点:
- 可控
- 逻辑直观
缺点:
- 容易对所有问题都做无意义搜索
- 规则越来越多
- 工具触发不够灵活
方案 B:模型决定何时触发工具
优点:
- 更贴近 Agent 模式
- 无需为每种问题维护复杂规则
- 扩展新工具时更自然
缺点:
- 需要更好的 tool schema 和 prompt 约束
- 工具次数和错误控制更重要
我的判断:
对于这种"联网问答助手"场景,我更推荐方案 B,但前提是:
- 工具 schema 简单
- 有轮次上限
- 有错误可见性
- 最好禁掉并行工具调用,先做清晰链路
当前项目正是这么做的。
对比三:工具结果直接返回文本 vs 结构化结果 + 文本摘要双通道
方案 A:只返回文本
优点:
- 简单
- 模型直接可用
- 原型开发速度快
缺点:
- 前端还要二次解析
- 容易脆弱
- UI 与文本格式耦合
方案 B:结构化结果给 UI,摘要文本给模型
优点:
- 前端更稳
- 后端更易演进
- 工具可视化能力更强
缺点:
- 协议更复杂
- 数据结构设计成本更高
我的判断:
- 原型期:方案 A 可以接受
- 产品期:默认推荐方案 B
当前 agui 属于一个很典型的"方案 A 起步,但应该往方案 B 演进"的状态。
十二、把 demo 提升成真实业务视角:这套架构最适合什么场景
如果只把它理解成"一个会聊天并且能搜网页的 demo",这套代码的价值会被低估。
在我看来,它更适合被改造成下面这类产品。
1. 企业研究助理
用户问:
- 某公司最近融资情况
- 某政策最新发布时间
- 某竞品最近产品更新
系统行为:
- 模型判断需要最新信息
- 调
web_search - 前端显示搜索中状态与引用来源
- 模型根据结果再总结回答
这个场景里,工具可见性非常重要。因为用户往往会追问:
- 你是从哪查到的?
- 你查了几条?
- 结论依据是什么?
2. 技术资料问答助手
如果把 web_search 换成"知识库检索"或"代码检索",这套 UI 协议仍然成立。
区别只是:
web_search卡片变成"文档命中卡片"- URL 变成文档路径或仓库文件路径
- 摘要变成 chunk 摘要
也就是说,它的价值不是绑定搜索 API,而是绑定工具可视化这件事本身。
3. 带操作能力的工作流助手
前端已经有 send_mail 面板,这其实已经暗示了一条很自然的演进路线:
- 搜索只是读操作
- 邮件是写操作
- 再往后可以接审批、工单、日程、CRM 等业务工具
而一旦进入写操作,UI 对"参数生成过程""等待确认""执行状态"的需求会更强。
这时,如果你还停留在纯文本聊天框,产品体验会非常差。
所以这类项目真正的方向,不是"更像聊天",而是"更像一个可解释、可执行的 AI 工作台"。
十三、关键配置与参数:哪些值看起来普通,实际上决定了系统行为
下面这些参数和配置,虽然代码不多,但非常值得在工程上单独解释。
| 配置项 | 位置 | 作用 | 我的判断 |
|---|---|---|---|
MODEL_NAME |
后端 CHAT_MODEL provider |
选择具体模型 | 应当保持可配置,不应写死 |
OPENAI_API_KEY |
后端 CHAT_MODEL provider |
鉴权 | 基础必需项 |
OPENAI_BASE_URL |
后端 CHAT_MODEL provider |
接 OpenAI 兼容网关 | 适合接兼容协议模型服务 |
BOCHA_API_KEY |
WEB_SEARCH_TOOL |
联网搜索鉴权 | 工具能力开关 |
MAX_TOOL_ROUNDS = 3 |
AiService |
限制最大工具轮次 | 对当前项目是合理默认值 |
parallel_tool_calls: false |
AiService 构造器 |
禁止并行工具调用 | 当前版本我非常赞成关闭 |
count ?? 10 |
搜索工具 | 默认返回结果数 | 对问答足够,过多反而加重模型负担 |
parseIncompleteMarkdown |
StreamdownText |
支持未闭合 Markdown 流式解析 | 技术问答场景强烈建议开启 |
API_BASE = http://localhost:3000 |
前端 App.tsx |
后端地址 | demo 可接受,产品期应改成环境配置 |
这里我还想额外指出两个非常值得注意的边界。
1. 当前后端配置只读本地 .env,对部署不够友好
AppModule 里是这样初始化 ConfigModule 的:
ts
ConfigModule.forRoot({
isGlobal: true,
envFilePath: join(__dirname, '..', '.env'),
ignoreEnvVars: true,
validate: (config) => config,
skipProcessEnv: true,
})
这个配置意味着它非常偏向本地开发:
- 指定固定
.env文件 - 忽略环境变量注入
- 不走通常的进程环境配置路径
对本地 demo 当然没问题,但如果你要上服务器、容器化部署或 CI/CD,这个选择会变成约束。
我的建议是:
- 本地开发可以优先读
.env - 但不要彻底忽略
process.env - 至少要给部署环境保留注入空间
2. 前端硬编码 API 地址,只适合单机联调
API_BASE 直接写成 http://localhost:3000,这在本地开发阶段完全正常。
但只要进入多环境:
- 开发环境
- 测试环境
- 预发布环境
- 生产环境
这个写法就会变成负担。
更稳妥的默认建议是:
- 用 Vite 环境变量管理 API base
- 或者通过同域部署 + proxy 规避前端硬编码
十四、这套实现的几个常见误区与真实局限
讲优点很容易,讲边界才更有价值。
误区一:看起来有 send_mail 面板,就以为系统已经支持发邮件
不是。
当前源码里,前端有 send_mail 的展示逻辑,但后端并没有对应工具实现。它更像一个预留接口,而不是当前已打通能力。
这提醒我们一个很重要的工程原则:
UI 预留能力,不等于链路已经闭合。
误区二:看起来用了 LangChain,就以为这是一个完整 Agent 框架
也不是。
当前系统没有:
- 会话持久化
- 多 Agent 协作
- 复杂任务规划
- 任务恢复
- 观察指标与 tracing
- 工具权限体系
它本质上仍然是一个带工具调用能力的流式聊天后端。
这不是贬义,反而是我欣赏它的地方:边界清楚。
误区三:只要前端能显示搜索卡片,就等于可信度问题解决了
也没有。
当前搜索结果仍然是:
- 后端格式化成文本
- 前端正则再解析
这是一种"够用"的方案,但不是最稳的方案。
如果以后你要做:
- 引用跳转
- 结果去重
- 可信度评分
- 结构化排序
- 多数据源聚合
你就会希望工具输出一开始就是结构化对象,而不是半结构化文本。
误区四:测试里测了流事件,就等于端到端没问题
当前后端测试重点在 AiService 的流事件顺序,这很好,但 /ai/chat 真实接口目前没有对应的端到端验证闭环。
这意味着:
- 服务层核心逻辑有一定测试支撑
- 但真正 HTTP 请求、序列化、响应头、前端 transport 协同这一层,仍然缺 e2e 证据
所以如果你要把它变成团队共用的工程模板,我会优先补 /ai/chat 的 e2e。
误区五:前端渲染已经很完整,就可以无限加工具
也不建议。
当前 ToolPanels 是显式白名单设计:
web_searchsend_mail- 其他工具走默认展示
这适合工具数量不多、每个工具都值得单独设计 UI 的场景。
如果工具数量快速膨胀,你就需要重新考虑:
- 哪些工具值得专门面板
- 哪些工具只需要默认 JSON 视图
- 是否要引入共享工具元数据描述
否则前端会慢慢变成一个不断增长的 switch-case 集合。
十五、如果让我继续演进这套项目,我会按这条路线走
我不会急着加更多工具,而会优先做下面几件事。
第一阶段:把当前最小链路收紧
这是最划算的一步。
-
补 README
- 明确
/ai/chat接口 - 写清消息协议与工具事件
- 写清环境变量
- 明确
-
修
.env.example- 让示例和实际代码一致
- 至少补上
OPENAI_API_KEY、OPENAI_BASE_URL、MODEL_NAME、BOCHA_API_KEY
-
补
/ai/chate2e- 不只是测首页
Hello World - 要测真实流式接口的响应行为
- 不只是测首页
-
把前端
API_BASE改成环境变量- 让联调和部署更自然
这一步不是"炫技升级",而是把原型变成一个可靠模板。
第二阶段:让工具结果结构化
这是体验和工程性都会明显提升的一步。
我会把搜索结果拆成两路:
- 给模型的摘要文本
- 给前端的结构化对象数组
这样前端就不必再正则解析文本块,而可以直接拿到:
- 标题
- URL
- 摘要
- 时间
- 来源站点
这会显著降低前后端耦合。
第三阶段:抽出共享的 tool contract
如果工具继续增多,我会考虑在前后端之间定义共享 contract,比如:
- 工具名
- 输入 schema
- 输出 schema
- 是否需要等待态 UI
- 默认渲染器类型
这样前端就不一定每加一个工具都写一次硬编码分支。
第四阶段:把"可视化工具调用"扩展成"可视化工作流"
当前系统适合的下一步不是"更花哨的聊天框",而是"更明确的工作流 UI"。
比如:
- 搜索 -> 总结 -> 发邮件
- 检索 -> 生成草稿 -> 人工确认 -> 执行
- 读取上下文 -> 规划步骤 -> 调多个工具 -> 汇总结论
一旦走到这一步,你会发现今天这套 message.parts + tool events 抽象,没有浪费,反而成了最重要的地基。
十六、这套架构适合什么,不适合什么
最后,把适用边界讲清楚。
适合的场景
- 需要流式输出的 AI 产品
- 需要把工具调用过程显式展示给用户
- 需要技术文档、搜索结果、结构化卡片类展示
- 希望前端保持轻、后端保持可扩展
- 希望从聊天型产品逐步演进到 Agent UI
不太适合的场景
-
纯 FAQ 客服
- 没有工具调用、没有复杂状态
- 直接纯文本流即可
-
高并发、极致轻量的简单回答框
- 过早上复杂 part/event 抽象不一定划算
-
强事务型、多权限、多审批的操作系统
- 这套协议能做 UI 地基,但还远远不够
- 你还需要确认流、审计流、权限流、补偿逻辑
所以我的默认判断是:
对"需要工具调用透明度的 AI 应用",这套架构是一个很好的起点;对"只有文本问答的轻量聊天框",它可能略重;对"复杂企业操作平台",它还只是开始。
总结:真正的 Agent UI,关键不是打字机效果,而是把系统过程变成产品能力
回到本文最开始的主结论。
很多人做 AI 前后端时,注意力会自然落在最显眼的东西上:
- 模型接没接上
- token 有没有流出来
- markdown 能不能渲染
- 页面看起来像不像 ChatGPT
但这套 agui 源码真正提醒我的,是另外一件事:
一旦系统开始使用工具,聊天 UI 的核心问题就不再是"如何显示文本",而是"如何显示过程"。
这个过程包括:
- 模型什么时候决定调用工具
- 工具参数是什么
- 工具当前处于什么状态
- 工具返回了什么
- 最终文本和工具结果怎么共存
- 用户中断后,系统能不能一起停下来
而这套项目给出的答案,我认为非常有工程价值:
- 后端不要只返回文本,要返回统一 UI 事件流
- 前端不要只渲染整条字符串,要渲染
message.parts - 工具不要藏在黑盒里,要成为可见的 UI 对象
- 限制轮次、限制并行、控制协议粒度,比一开始追求"万能 Agent"更重要
如果你今天正在做一个带检索、带工具、带操作能力的 AI 产品,我的建议很明确:
不要把它做成"会打字的文本框"。
先把"文本、工具、状态、错误、取消"统一进一条协议里。等这件事做对了,UI 才会越来越轻,工具才会越加越稳,产品也才真正有机会从聊天 demo 演进成可用系统。
这也是这套小项目最值得学的地方。