Agent 开发实战(五):流式输出与前端集成——把 Agent 做成能用的聊天产品

Moryu 4 阅读

Agent 开发实战(五):流式输出与前端集成——把 Agent 做成能用的聊天产品

前几篇 Agent 都是"问一句、等几秒、一次性吐答案"。真实产品里用户受不了这种黑盒等待。本篇把 Agent 包成一个流式聊天接口,并用 Vue3 写个能打字机式渲染的前端,让它真正"像产品"。

1. 为什么需要流式

  • 体验:token 一个一个出来,用户立刻知道"在跑了",等待焦虑大幅下降。
  • 可中断:用户点"停止"能立刻停,不用等整段生成完。
  • 省内存:后端边生成边往外推,不用攒完整响应。

Agent 分两段:工具调用阶段不流式(这些是确定性的后台动作),最终回答生成阶段流式(LLM 逐 token 输出)。

2. 后端:FastAPI + SSE

SSE(Server-Sent Events)比 WebSocket 简单:单向、基于 HTTP、天然适配"服务端推、客户端收"。

# server.py
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import json

app = FastAPI()

def event(payload: dict) -> str:
    return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"

def agent_stream(question: str):
    messages = [{"role":"system","content":SYSTEM_PROMPT},
                {"role":"user","content":question}]
    # 先处理工具调用(非流式)
    while True:
        resp = client.chat.completions.create(
            model=os.getenv("LLM_MODEL"), messages=messages,
            tools=registry.openai_schemas(), tool_choice="auto", stream=False)
        msg = resp.choices[0].message
        if not msg.tool_calls:
            break
        messages.append(msg)
        for call in msg.tool_calls:
            res = execute_one(call)
            messages.append({"role":"tool","tool_call_id":call.id,
                             "content": compress_tool_result(res)})
        yield event({"type":"tool", "name": msg.tool_calls[0].function.name})

    # 工具结束后,流式生成最终回答
    stream = client.chat.completions.create(
        model=os.getenv("LLM_MODEL"), messages=messages, stream=True)
    for chunk in stream:
        delta = chunk.choices[0].delta.content or ""
        if delta:
            yield event({"type":"token", "content": delta})

@app.post("/api/agent/ask")
async def ask(req: dict, request: Request):
    return StreamingResponse(agent_stream(req["question"]),
                             media_type="text/event-stream")

关键点:工具阶段用 stream=False 一次性拿结果(需要完整 tool_calls 才能正确回灌);只有最后那段自然语言回答才用 stream=True 逐 token 推。

3. 前端:Vue3 聊天组件

用组合式 API 封装一个 useAgent,负责发请求、读 SSE、累积 token。

// composables/useAgent.ts
import { ref } from 'vue'

export function useAgent() {
  const answer = ref('')
  const tools = ref<string[]>([])
  const loading = ref(false)
  const controller = new AbortController()

  async function send(question: string) {
    loading.value = true
    answer.value = ''
    tools.value = []
    try {
      const res = await fetch('/api/agent/ask', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ question }),
        signal: controller.signal,
      })
      const reader = res.body!.getReader()
      const decoder = new TextDecoder()
      let buf = ''
      while (true) {
        const { done, value } = await reader.read()
        if (done) break
        buf += decoder.decode(value, { stream: true })
        const lines = buf.split('\n\n')
        buf = lines.pop() || ''
        for (const line of lines) {
          if (!line.startsWith('data:')) continue
          const payload = JSON.parse(line.slice(5).trim())
          if (payload.type === 'tool') tools.value.push(payload.name)
          if (payload.type === 'token') answer.value += payload.content
        }
      }
    } finally {
      loading.value = false
    }
  }

  function stop() { controller.abort() }
  return { answer, tools, loading, send, stop }
}
<!-- components/ChatWindow.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useAgent } from '@/composables/useAgent'
const { answer, tools, loading, send, stop } = useAgent()
const input = ref('')
function submit() {
  if (!input.value.trim() || loading.value) return
  send(input.value.trim())
  input.value = ''
}
</script>

<template>
  <div class="chat">
    <div class="tools" v-if="tools.length">正在调用:{{ tools.join(' → ') }}</div>
    <div class="answer">{{ answer }}<span v-if="loading" class="cursor">▋</span></div>
    <div class="input">
      <input v-model="input" @keyup.enter="submit" placeholder="问点什么…" />
      <button @click="submit" :disabled="loading">发送</button>
      <button v-if="loading" @click="stop">停止</button>
    </div>
  </div>
</template>

这就是一个最小可跑的聊天界面:提问 → 实时看到"正在调用哪个工具" → 答案逐字浮现 → 可随时停止。

4. SSE vs WebSocket

维度 SSE WebSocket
方向 服务端 → 客户端 双向
复杂度 低(普通 HTTP) 高(需协议握手)
断线重连 浏览器原生支持 自己实现
适用 单向流式推送(本场景) 需要客户端频繁发消息/游戏等

Agent 回答是单向推送,SSE 更合适。只有当你需要"客户端边说边打断、双向流式"时才上 WebSocket。

5. 错误处理与中断

  • 中断:前端 controller.abort() 会断开 SSE,后端 StreamingResponse 的 generator 抛出 GeneratorExit,资源自然释放。
  • 错误:工具阶段出错,可在 SSE 里先推一个 {"type":"error",...} 事件,前端展示,而不是静默卡死。
  • 超时:给后端 agent 整体加一个超时(如 60s),超时则 yield 错误事件并结束流。

6. 小结

本篇把 Agent 从"脚本"变成"产品":

  • 后端用 FastAPI + SSE,工具阶段非流式、回答阶段流式;
  • 前端用 Vue3 组合式封装 useAgent,读流、累加、可中断;
  • SSE 而非 WebSocket,契合单向流式场景。

到这一步,你的博客后台已经能挂一个真正能对话、能查多源数据、能实时反馈的"AI 分析助手"了。

流式不是炫技,是"让用户知道系统在动"。哪怕后端逻辑没变,加上这一层,可用性立刻不同。