Agent 开发实战(五):流式输出与前端集成——把 Agent 做成能用的聊天产品
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 分析助手"了。
流式不是炫技,是"让用户知道系统在动"。哪怕后端逻辑没变,加上这一层,可用性立刻不同。