一、联调才开始,就遇到了第一个问题

进入联调阶段之前,我以为 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 只在两边真正连起来的时候才会出现。

Logo

脑启社区是一个专注类脑智能领域的开发者社区。欢迎加入社区,共建类脑智能生态。社区为开发者提供了丰富的开源类脑工具软件、类脑算法模型及数据集、类脑知识库、类脑技术培训课程以及类脑应用案例等资源。

更多推荐