AI 模块与前端的联调:流式输出和状态同步有多烦
一、联调才开始,就遇到了第一个问题
进入联调阶段之前,我以为 AI 模块和前端的对接应该很顺——接口契约定好了,JSON Schema 定好了,状态机设计好了,两边按照契约开发,联调应该是"插上就能跑"的事。
然后第一次联调,杨奕天给我看了一个界面:用户点击"开始检查"之后,页面在 loading,等了大概 8 秒,然后所有内容一次性弹出来。
"就这样?"他问。
就这样。Agent 在后端跑完整个 ReAct 循环(Thought → Action → Observation → 输出),拿到完整结果再一次性返回,前端在这 8 秒里什么都看不见。
这在演示场景下说不过去,在真实使用场景里更说不过去——用户不知道系统在干什么,8 秒的黑盒等待体验很差。
二、问题一:流式输出
方案选型
解决等待体验最直接的方式是流式输出(Streaming):让后端在生成 Token 的同时就开始往前端推,用户能看到文字逐渐出现,而不是等到全部生成完再显示。
FastAPI 原生支持 StreamingResponse,LangChain 也有流式回调接口,技术上可行。但我们面临一个选择:流什么?
选项一:流 LLM 的原始 Token(包括 Thought、Action、Observation 全过程) 选项二:只流最终输出的 Token,隐藏中间推理过程 选项三:流结构化的"进度事件",前端渲染进度条而不是原始文字
我们选了选项三,原因是:
选项一会把"Thought: 我需要提取药物实体……Action: query_drug_graph……"这些内部推理步骤暴露给用户,对普通用户来说完全是噪声,而且一旦暴露了推理过程,就隐含地要求这些步骤的措辞对用户友好,增加了 Prompt 的设计负担。
选项二比一好,但 LLM 生成中间步骤的时间占了总耗时的大头,只流最终输出意味着用户等待的时间和选项一一样长,只是最后几个 Token 是流式的,体验改善有限。
Server-Sent Events 实现
选项三用 SSE(Server-Sent Events)实现——后端在处理的不同阶段推送结构化的进度事件,前端订阅这些事件并更新 UI:
# api/routes/consult.py
from fastapi.responses import StreamingResponse
import json, asyncio
async def consultation_event_stream(user_input: str):
"""生成 SSE 事件流"""
def sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
# 阶段1:实体提取开始
yield sse("progress", {"stage": "extracting", "message": "正在识别药物信息…"})
try:
# 运行 Agent(同步包装成异步)
agent_result = await asyncio.wait_for(
run_agent_with_callbacks(user_input, progress_callback=lambda s: None),
timeout=25.0
)
# 阶段2:图谱查询
yield sse("progress", {"stage": "checking", "message": "正在查询用药安全数据库…"})
await asyncio.sleep(0) # 让事件循环有机会推送上一条
# 阶段3:生成结论
yield sse("progress", {"stage": "analyzing", "message": "正在生成安全评估…"})
await asyncio.sleep(0)
# 最终结果
yield sse("result", agent_result)
except asyncio.TimeoutError:
yield sse("result", build_fallback_response("超时"))
except Exception as e:
yield sse("result", build_fallback_response(str(e)))
@router.get("/api/consult/stream")
async def consult_stream(input: str):
return StreamingResponse(
consultation_event_stream(input),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
)
三、问题二:状态同步的时序问题
流式事件上了之后,出现了一个新问题:偶尔前端会先收到 result 事件,再收到最后一条 progress 事件,导致进度条在"结果已显示"之后还在动。
原因是 SSE 的事件推送是异步的,FastAPI 里的 yield 不保证事件以严格顺序到达前端——特别是两个 yield 之间没有 await 的情况下,事件可能被合并推送。
解决方案很简单:给每个 SSE 事件加一个递增的 seq 字段,前端按序号处理,忽略乱序到达的旧事件:
# 后端加序号
seq = 0
def sse(event, data):
nonlocal seq
seq += 1
return f"event: {event}\ndata: {json.dumps({**data, 'seq': seq}, ensure_ascii=False)}\n\n"
// 前端忽略乱序事件
let lastSeq = 0
es.addEventListener('progress', (e) => {
const data = JSON.parse(e.data)
if (data.seq <= lastSeq) return // 忽略旧事件
lastSeq = data.seq
stage.value = data.stage
message.value = data.message
})
两行代码,问题消失。但找这个 bug 花了将近半天,因为它不是必现的,只在网络稍微有点延迟的时候才复现。
四、问题三:降级状态的前端感知
状态机有四种终态:BLOCKED / PASS / CLARIFY / FALLBACK。前三种的前端渲染在杨奕天那边已经实现了(博客四的团队周报里有记录),FALLBACK 是最后才联调的。
FALLBACK 状态有一个特殊之处:它可能在 SSE 流的任何阶段触发——可能在"正在识别药物信息"阶段就触发(LLM 超时),也可能在"正在生成评估"阶段触发(Output Parser 失败)。前端需要在收到 result 事件且 action_type === 'FALLBACK' 时,把进度条动画立刻停掉,切换到降级界面。
这里有一个细节:进度条动画是 CSS 动画,不会因为 JavaScript 修改数据而立即停止。需要在检测到 FALLBACK 时,强制清除动画:
es.addEventListener('result', (e) => {
const data = JSON.parse(e.data)
result.value = data
if (data.action_type === 'FALLBACK') {
stage.value = 'fallback'
// 强制停止所有进行中的 CSS 动画
document.querySelectorAll('.progress-animation').forEach(el => {
el.style.animationPlayState = 'paused'
})
} else {
stage.value = 'done'
}
isStreaming.value = false
es.close()
})
另一个小问题是:FALLBACK 界面上的"审查时间线"(博客五里提到的,让用户知道系统尝试过什么)需要知道 Agent 在哪个阶段失败的。这个信息通过 fallback_reason 字段传递,但前端不直接展示原始错误信息("asyncio.TimeoutError"对用户没有意义),而是把它映射成可读的描述:
const fallbackStageMap = {
'extracting': '药物信息识别阶段',
'checking': '用药安全数据库查询阶段',
'analyzing': '安全评估生成阶段',
}
const failedStageLabel = computed(() =>
fallbackStageMap[stage.value] || '处理过程中'
)
前端渲染:"系统在用药安全数据库查询阶段遇到了问题,无法完成本次安全审查。"比直接显示报错信息友好得多。
五、小结
联调阶段遇到的三个问题,回头看都不大:流式输出是 SSE 的标准用法,时序问题一个 seq 字段搞定,降级状态的感知是几行条件判断。每个单独看都不难,但它们叠在一起,加上"必须在演示前修好"的时间压力,实际上花了将近整整一周。
这大概就是"联调"这件事的真实面目:不是任何单个问题很难,而是需要前后端两个人持续对齐,每修一个问题就可能引出下一个问题,而且很多 bug 只在两边真正连起来的时候才会出现。
更多推荐



所有评论(0)