别再把 AI 聊天做成纯文本:从 agui 这个前后端项目,拆解“可感知工具调用”的流式 AI UI

大多数 AI 聊天 demo,一开始都长得差不多:一个输入框,一个后端接口,一段流式输出,前端把 token 一点点打印到页面上。

这个阶段看起来很顺,甚至会让人产生一种错觉:AI 聊天系统最难的事情,无非就是把模型输出流式渲染出来。

但你只要再往前走一步,比如给模型加一个联网搜索工具、一个邮件发送工具,或者一个知识库检索能力,问题马上就变了。

因为这时候前端面对的就不再只是"文本"。它还要面对:

  • 工具什么时候被调用
  • 工具参数有没有生成完成
  • 工具执行成功还是失败
  • 工具输出应该渲染成普通文本,还是渲染成结构化卡片
  • 最终回答是模型直接生成的,还是基于工具结果二次生成的
  • 用户点击停止后,后端能不能把模型和工具一起中断掉

也就是说,真正难的地方不在 LLM API,而在 UI 协议。

我这次看的是一个很小、但很有代表性的项目:agui-backend + agui-frontend。它的代码量并不大,README 甚至还是模板级别,但源码里已经出现了一个很值得复用的工程思路:

一个真正可用的 AI 聊天系统,关键不是"把模型回复流式打印出来",而是"让文本、工具输入、工具输出、错误和取消都成为同一条 UI 消息协议的一部分"。

这篇文章我就围绕这个主结论展开,拆掉它背后的前后端实现思路、工程取舍和演进边界。


先给结论:这套实现真正值得学的,不是聊天框,而是"统一事件流"

先把结论说得更具体一点。

这套项目的核心价值,不是它用了 NestJS、React、LangChain 或 AI SDK 这些词本身,而是它把下面几件事放进了一条统一链路里:

  1. 前端输入的是 message.parts
  2. 后端输出的不是单纯文本,而是 UIMessageChunk
  3. 模型流式文本会被拆成 text-starttext-deltatext-end
  4. 工具调用会被拆成 tool-input-availabletool-output-availabletool-output-error
  5. 前端不是按"整条 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 能力真正落在 AiModuleAiControllerAiService 上。

前端边界

前端是一个单页 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 渲染
  • textStreamdown 处理
  • toolToolPanels 处理
  • 不同工具、不同状态走不同组件分支

这很重要。因为前端不是靠字符串猜流程,而是靠协议拿到流程。


四、系统架构:这条链路到底是怎么串起来的

先看整体架构图。

flowchart LR U[用户输入] --> F1[React App useChat] F1 --> F2[DefaultChatTransport] F2 --> B1[POST /ai/chat] B1 --> B2[AiController] B2 --> B3[AiService] B3 --> M[ChatOpenAI] M -->|tool call| T[web_search 工具] T --> S[Bocha Web Search API] S --> T T --> B3 B3 -->|UIMessageChunk| B2 B2 --> F2 F2 --> F3[message.parts] F3 --> TXT[StreamdownText] F3 --> TOOL[ToolPanels] TOOL --> WS[搜索结果卡片] TOOL --> ERR[错误/等待态]

如果把它翻译成一句工程语言,就是:

React 前端负责消费 UIMessage,Nest 后端负责生产 UIMessageChunk,LangChain 负责模型与工具调用,AI SDK 负责把这套事件流从服务端送到前端。

注意这里的边界非常清晰:

  • 模型是否调用工具:由模型与 tool binding 决定
  • 工具怎么执行:由后端 tool provider 决定
  • 工具过程怎么呈现:由消息事件协议决定
  • 最终长什么样:由前端组件决定

这就是一个很干净的分层。


五、请求时序:一次对话是怎么从输入走到最终 UI 的

再看一次典型时序。

sequenceDiagram participant User as 用户 participant FE as 前端 App/useChat participant API as AiController participant SVC as AiService participant LLM as ChatOpenAI participant Tool as web_search User->>FE: 输入问题并发送 FE->>API: POST /ai/chat API->>SVC: stream(messages, signal) SVC->>LLM: stream(history) alt 模型直接回答 LLM-->>SVC: text chunks SVC-->>FE: text-start / text-delta / text-end else 模型触发工具 LLM-->>SVC: tool_calls SVC-->>FE: tool-input-available SVC->>Tool: invoke(toolCall) Tool-->>SVC: tool result SVC-->>FE: tool-output-available SVC->>LLM: 带 ToolMessage 的下一轮生成 LLM-->>SVC: final text chunks SVC-->>FE: text-start / text-delta / text-end end

这张图其实已经解释了为什么这套系统比"纯文本流式输出"更适合工具型 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,
  })
}

这段代码做了四件事:

  1. 校验输入是否包含 messages
  2. 为本次请求创建 AbortController
  3. 监听连接关闭,把中断信号往下游传
  4. 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: false
  • MAX_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,有:

  • name
  • description
  • schema

这代表它不只是给开发者看的,而是给模型看的。

模型知道:

  • 这个工具叫什么
  • 它适合干什么
  • 它接收什么参数

这就使得"模型决定是否调用工具"成为可能。

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 的需求。

一个工具调用并不只有结果,它至少有三类状态:

  1. 正在决定怎么调用
  2. 正在调用
  3. 已返回结果或已失败

如果 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_search
  • send_mail
  • 其他工具走默认展示

这适合工具数量不多、每个工具都值得单独设计 UI 的场景。

如果工具数量快速膨胀,你就需要重新考虑:

  • 哪些工具值得专门面板
  • 哪些工具只需要默认 JSON 视图
  • 是否要引入共享工具元数据描述

否则前端会慢慢变成一个不断增长的 switch-case 集合。


十五、如果让我继续演进这套项目,我会按这条路线走

我不会急着加更多工具,而会优先做下面几件事。

第一阶段:把当前最小链路收紧

这是最划算的一步。

  1. 补 README

    • 明确 /ai/chat 接口
    • 写清消息协议与工具事件
    • 写清环境变量
  2. .env.example

    • 让示例和实际代码一致
    • 至少补上 OPENAI_API_KEYOPENAI_BASE_URLMODEL_NAMEBOCHA_API_KEY
  3. /ai/chat e2e

    • 不只是测首页 Hello World
    • 要测真实流式接口的响应行为
  4. 把前端 API_BASE 改成环境变量

    • 让联调和部署更自然

这一步不是"炫技升级",而是把原型变成一个可靠模板。

第二阶段:让工具结果结构化

这是体验和工程性都会明显提升的一步。

我会把搜索结果拆成两路:

  • 给模型的摘要文本
  • 给前端的结构化对象数组

这样前端就不必再正则解析文本块,而可以直接拿到:

  • 标题
  • URL
  • 摘要
  • 时间
  • 来源站点

这会显著降低前后端耦合。

第三阶段:抽出共享的 tool contract

如果工具继续增多,我会考虑在前后端之间定义共享 contract,比如:

  • 工具名
  • 输入 schema
  • 输出 schema
  • 是否需要等待态 UI
  • 默认渲染器类型

这样前端就不一定每加一个工具都写一次硬编码分支。

第四阶段:把"可视化工具调用"扩展成"可视化工作流"

当前系统适合的下一步不是"更花哨的聊天框",而是"更明确的工作流 UI"。

比如:

  • 搜索 -> 总结 -> 发邮件
  • 检索 -> 生成草稿 -> 人工确认 -> 执行
  • 读取上下文 -> 规划步骤 -> 调多个工具 -> 汇总结论

一旦走到这一步,你会发现今天这套 message.parts + tool events 抽象,没有浪费,反而成了最重要的地基。


十六、这套架构适合什么,不适合什么

最后,把适用边界讲清楚。

适合的场景

  1. 需要流式输出的 AI 产品
  2. 需要把工具调用过程显式展示给用户
  3. 需要技术文档、搜索结果、结构化卡片类展示
  4. 希望前端保持轻、后端保持可扩展
  5. 希望从聊天型产品逐步演进到 Agent UI

不太适合的场景

  1. 纯 FAQ 客服

    • 没有工具调用、没有复杂状态
    • 直接纯文本流即可
  2. 高并发、极致轻量的简单回答框

    • 过早上复杂 part/event 抽象不一定划算
  3. 强事务型、多权限、多审批的操作系统

    • 这套协议能做 UI 地基,但还远远不够
    • 你还需要确认流、审计流、权限流、补偿逻辑

所以我的默认判断是:

对"需要工具调用透明度的 AI 应用",这套架构是一个很好的起点;对"只有文本问答的轻量聊天框",它可能略重;对"复杂企业操作平台",它还只是开始。


总结:真正的 Agent UI,关键不是打字机效果,而是把系统过程变成产品能力

回到本文最开始的主结论。

很多人做 AI 前后端时,注意力会自然落在最显眼的东西上:

  • 模型接没接上
  • token 有没有流出来
  • markdown 能不能渲染
  • 页面看起来像不像 ChatGPT

但这套 agui 源码真正提醒我的,是另外一件事:

一旦系统开始使用工具,聊天 UI 的核心问题就不再是"如何显示文本",而是"如何显示过程"。

这个过程包括:

  • 模型什么时候决定调用工具
  • 工具参数是什么
  • 工具当前处于什么状态
  • 工具返回了什么
  • 最终文本和工具结果怎么共存
  • 用户中断后,系统能不能一起停下来

而这套项目给出的答案,我认为非常有工程价值:

  • 后端不要只返回文本,要返回统一 UI 事件流
  • 前端不要只渲染整条字符串,要渲染 message.parts
  • 工具不要藏在黑盒里,要成为可见的 UI 对象
  • 限制轮次、限制并行、控制协议粒度,比一开始追求"万能 Agent"更重要

如果你今天正在做一个带检索、带工具、带操作能力的 AI 产品,我的建议很明确:

不要把它做成"会打字的文本框"。

先把"文本、工具、状态、错误、取消"统一进一条协议里。等这件事做对了,UI 才会越来越轻,工具才会越加越稳,产品也才真正有机会从聊天 demo 演进成可用系统。

这也是这套小项目最值得学的地方。

相关推荐
GetcharZp1 小时前
GitHub 爆火!纯 Go 编写的文件同步神器 Syncthing,凭什么成为程序员的标配?
后端
hERS EOUS1 小时前
SpringBoot 使用 spring.profiles.active 来区分不同环境配置
spring boot·后端·spring
LucianaiB2 小时前
我用飞书多维表做了一个 AI 活动推荐智能体:每天自动催我别错过截止日期!
后端
TheRouter2 小时前
Agent Harness系列(三):记忆层的3种持久化架构——从SQLite到向量库
人工智能·架构·sqlite·llm·ai-native
铁皮饭盒3 小时前
第2课:5分钟!用 Trae AI 生成你的第一个后端服务(Bunjs + Elysia)
前端·后端·全栈
金銀銅鐵3 小时前
[git] 浅解 git reset 命令
git·后端
xiaoye37083 小时前
Spring 事务传播机制 + 隔离级别
java·后端·spring
Baihai_IDP4 小时前
为什么 AI Agent 重新爱上了文件系统(Filesystems)
人工智能·llm·agent