流式大模型调用中的首包监测:解决流式调用大模型的异步问题

18 阅读12分钟

流式大模型调用中的首包监测:解决异步错误与模型切换的窗口期

适合场景:RAG 应用、智能客服、知识库问答、Agent 系统、本地大模型推理服务、需要支持流式输出和多模型故障转移的后端系统。

前言

在大模型应用里,同步调用和流式调用的错误处理方式差异很大。

同步调用比较好理解:调用模型 A 失败了,当前线程还能继续尝试模型 B;如果 B 成功,就直接返回结果。整个故障转移过程发生在 return 之前,调用方一直阻塞等待,完全不知道中间切换过几个供应商,最终拿到一个字符串结果即可。

流式调用就不一样了。流式接口通常会立即返回一个取消句柄,真正的数据则在异步线程里通过回调持续推送。也就是说,方法返回时,回答并没有生成完,甚至可能还没有生成第一个 token。

这会带来一个很隐蔽的问题:如果供应商 A 在流式请求启动后、首个 token 返回前失败,路由层想切换到供应商 B,但 A 的取消句柄可能已经返回给调用方了。此时即使 B 能重新发起流式请求,也会产生一个新的取消句柄,而调用方手里仍然拿着 A 的旧句柄。

这个问题在本地部署模型上尤其常见。

例如 Ollama、vLLM 这类本地推理服务在承压时可能出现这种情况:

HTTP 连接建立成功
模型服务接受了请求
请求进入推理队列或开始准备推理
但在生成第一个 token 之前进程崩溃

常见原因包括:

GPU 显存溢出
推理进程 OOM
模型服务被重启
请求排队过久
推理引擎内部异常

这个窗口期很尴尬:HTTP 层没有立即报错,熔断器的历史健康状态也可能显示正常,但真正的错误发生在异步推流线程里。等异步线程回调 onError 时,流式方法可能早就返回了。

云 API 通常有负载均衡、实例隔离和平台级容灾保护,出现这种情况的概率相对较低。但本地模型服务直接暴露推理进程,资源承压时更容易暴露这个窗口期。

首包监测要解决的就是这个问题:在把取消句柄返回给调用方之前,先确认当前模型至少成功产出了第一段流式内容。

技术原理

1. 为什么同步调用容易故障转移

同步调用的流程通常是这样的:

调用方
  -> 路由服务
    -> 调用模型 A
      -> 失败
    -> 标记模型 A 失败
    -> 调用模型 B
      -> 成功
  -> return 最终结果

因为整个过程都在同一个调用链里,方法没有提前返回,所以路由层可以在内部完成多次尝试。调用方只关心最终结果,不需要知道中间失败过几次。

伪代码可以写成:

for (ModelTarget target : targets) {
    try {
        String result = client.chat(request, target);
        markSuccess(target);
        return result;
    } catch (Exception e) {
        markFailure(target);
        lastError = e;
    }
}
throw new RemoteException("所有模型都失败", lastError);

这种方式对同步请求很自然。

2. 为什么流式调用不能直接照搬同步逻辑

流式调用通常长这样:

StreamCancellationHandle handle = client.streamChat(request, callback, target);
return handle;

streamChat 返回以后,真正的内容还在异步线程里继续推送:

异步线程
  -> onContent("第一段")
  -> onContent("第二段")
  -> onComplete()

如果按照这种直接返回的方式,问题会出现在这里:

调用模型 A
  -> 返回 handleA 给调用方
  -> 异步线程稍后发现模型 A 首包前失败
  -> callback.onError(error)

这时路由层已经没有机会优雅地把 handleB 交给调用方了。

如果强行在异步线程里切换模型 B,会遇到几个问题:

调用方手里拿着的是 handleA,不是 handleB
取消时无法取消真正正在工作的模型 B
前端可能已经收到 A 的错误事件
不同模型之间生成内容不连续
可能出现重复 token 或半截回答

因此流式故障转移必须收敛到一个边界:首包成功之前可以切换,首包成功之后就正式交给调用方。

3. 首包监测的核心思路

首包监测的关键设计是引入一个桥接回调。

业务方传入的真实回调不直接交给模型供应商,而是先包一层:

真实 callback
  <- ProbeStreamBridge
    <- 模型供应商异步流

整体流程如下:

业务方调用 streamChat(request, callback)
  -> 路由层选择候选模型列表
  -> 创建 ProbeStreamBridge,包住真实 callback
  -> 调用供应商 client.streamChat(request, bridge, target)
  -> 供应商启动异步流式请求
  -> 返回当前供应商的取消句柄 handle
  -> 路由层暂时不 return handle
  -> 路由层等待 bridge 的首包探测结果

这个等待不是等完整回答,而是只等第一类有效事件:

onContent   收到正式内容,认为首包成功
onThinking  收到思考内容,也认为首包成功
onError     首包前失败
onComplete  没有内容就结束,认为无内容
timeout     超过阈值仍然没有首包,认为超时

只有首包成功,路由层才会返回当前供应商的取消句柄。

首包成功
  -> 标记当前模型成功
  -> 提交缓冲区
  -> return 当前 handle

如果首包失败、超时或无内容:

首包失败
  -> 标记当前模型失败
  -> 调用当前 handle.cancel()
  -> 取消当前供应商的 HTTP 流
  -> 丢弃当前 bridge
  -> 尝试下一个候选模型

4. 成功场景的数据流

假设候选模型顺序是:

模型 A:百炼
模型 B:硅基流动

模型 A 首包成功时,数据流如下:

这里有一个细节:第一段内容不会在 onContent 进入桥接器时立刻发给前端,而是先缓存起来。等路由层确认首包成功后,再统一放行。

这样做的目的是保证顺序:

先确认模型可用
再把内容交给真实 callback
最后把 handle 返回给调用方

5. 失败切换场景的数据流

再看模型 A 首包前失败的情况。

接着模型 B 成功:

对前端来说,整个过程就像模型 A 从来没有被尝试过。

前端只会看到:

模型 B 的第一段内容
模型 B 的后续内容
模型 B 的完成事件

不会看到模型 A 的错误。

6. 为什么第一阶段的错误不会被前端知道

关键在于桥接器不会直接调用真实 callback。

收到错误时,它不是这样做:

downstream.onError(error);

而是把这个动作包装起来:

bufferOrDispatch(() -> downstream.onError(error));

如果当前还没有确认首包成功,committedfalse,这个动作只会进入缓冲区:

buffer.add(() -> downstream.onError(error))

此时真实 callback 没有被调用,前端也就不会收到错误。

只有探测结果是 SUCCESS 时,桥接器才会执行 commit()

commit()
  -> committed = true
  -> 执行 buffer 里的动作

首包失败时不会 commit,所以模型 A 的错误事件一直留在 bridgeA 的缓冲区里。随后路由层取消 handleA,继续尝试模型 B,bridgeA 不再被引用,最终等待 GC 回收。

这就是为什么首包前的供应商错误可以被路由层内部消化,而不会污染前端体验。

7. 缓冲机制如何工作

桥接器里通常需要三个核心字段:

class ProbeStreamBridge implements StreamCallback {
    private final StreamCallback downstream;
    private final CompletableFuture<ProbeResult> probe = new CompletableFuture<>();
    private final Object lock = new Object();
    private final List<Runnable> buffer = new ArrayList<>();
    private volatile boolean committed;
}

含义如下:

downstream  真实业务 callback
probe       首包探测结果,负责唤醒路由线程
buffer      首包确认前暂存的回调动作
committed   是否已经确认当前模型可用
lock        保护 buffer 和 committed 的并发访问

收到内容时:

public void onContent(String content) {
    probe.complete(ProbeResult.success());
    bufferOrDispatch(() -> downstream.onContent(content));
}

收到错误时:

public void onError(Throwable error) {
    probe.complete(ProbeResult.error(error));
    bufferOrDispatch(() -> downstream.onError(error));
}

bufferOrDispatch 负责判断当前事件应该缓存还是直接转发:

private void bufferOrDispatch(Runnable action) {
    boolean dispatchNow;
    synchronized (lock) {
        dispatchNow = committed;
        if (!dispatchNow) {
            buffer.add(action);
        }
    }
    if (dispatchNow) {
        action.run();
    }
}

逻辑很简单:

committed = false
  -> 首包还没被路由层确认成功
  -> 事件进入 buffer,不转发

committed = true
  -> 当前模型已经正式接管
  -> 事件直接 action.run()
  -> 转发给真实 callback

commit 负责正式放行:

private void commit() {
    synchronized (lock) {
        if (committed) {
            return;
        }
        committed = true;
        buffer.forEach(Runnable::run);
    }
}

首包成功后,路由线程调用 commit(),把之前缓存的第一段内容推给真实 callback。之后再来的内容仍然会经过 bufferOrDispatch,只是因为 committed = true,所以会直接执行,不再进入缓冲区。

8. 为什么 commit 和 bufferOrDispatch 需要加锁

首包监测涉及两个线程:

路由线程:
  streamChat()
    -> awaitFirstPacket()
    -> commit()

异步推流线程:
  doStream()
    -> callback.onContent()
    -> bridge.onContent()
    -> bufferOrDispatch()

两者可能并发执行。

尤其是收到首包时:

public void onContent(String content) {
    probe.complete(ProbeResult.success());
    bufferOrDispatch(() -> downstream.onContent(content));
}

probe.complete(...) 会唤醒正在等待的路由线程。此时异步线程还没来得及把第一段内容放入 buffer,路由线程可能已经开始执行 commit()

如果没有锁,可能发生这样的竞态:

结果就是第一段内容丢失。

所以必须用同一把锁保护这组复合操作:

读取 committed
写入 buffer
设置 committed
遍历 buffer

volatile 只能保证可见性,不能保证“判断状态 + 修改集合”这类复合操作的原子性。这里需要锁。

9. 取消句柄在首包失败时做了什么

首包探测阶段拿到的 handle,就是当前正在尝试的供应商返回的取消句柄。

例如先尝试百炼:

bridgeA = new ProbeStreamBridge(callback)
handleA = baiLianClient.streamChat(request, bridgeA, targetA)
result = awaitFirstPacket(bridgeA)

如果 result 不是 SUCCESS,路由层会调用:

handleA.cancel();

这本质上是在取消百炼当前这条流式 HTTP 请求。

底层通常会做两件事:

设置 cancelled 标记,通知异步读取循环停止
调用 HTTP Call.cancel(),中断底层连接或读取阻塞

这样可以避免失败供应商的异步线程继续读取、继续回调、继续占用资源。

然后路由层再尝试下一个供应商。

10. 首包监测和熔断器的关系

首包监测不是为了替代熔断器,而是补齐熔断器覆盖不到的窗口期。

熔断器主要依赖历史状态:

过去是否连续失败
是否处于 OPEN 状态
是否允许半开探测

它解决的是“已知不健康模型不要继续打流量”的问题。

首包监测解决的是“模型看起来能调用,但当前这次流式请求在首 token 前失败”的问题。

两者组合起来更完整:

调用前:
  熔断器判断这个模型当前是否允许调用

调用后、return 前:
  首包监测判断这次流式请求是否真正开始产出

首包失败:
  标记失败,更新健康状态,取消当前流,尝试下一个模型

对于本地模型,这个组合很有价值。

11. 适用边界和取舍

首包监测解决的是首包之前的问题,不解决首包之后的问题。

一旦首包成功:

内容已经开始推给前端
当前供应商的 handle 已经返回给调用方
回答上下文已经由当前模型接管

此后如果中途失败,通常不建议自动切换到另一个模型继续生成。原因包括:

不同模型生成风格和上下文状态不一致
很难保证 token 不重复、不缺失
前端可能已经展示了部分回答
调用方手里的取消句柄无法无感替换

所以更合理的边界是:

首包前失败:
  后端内部切换,前端无感

首包后失败:
  作为当前流的错误处理,通知前端或提示重试

这也是首包监测最重要的设计边界。

总结

流式大模型调用的难点不在于“怎么发起请求”,而在于“请求发起之后、首个 token 返回之前”这个异步窗口期。

在这个阶段:

HTTP 连接可能已经成功
熔断器可能认为模型健康
取消句柄还没正式交给调用方
异步线程可能马上报错

首包监测通过桥接回调解决这个问题:

用 ProbeStreamBridge 包住真实 callback
首包成功前缓存所有回调事件
路由线程等待首包结果
成功则 commit 并返回当前 handle
失败则不 commit,取消当前 handle,尝试下一个模型

这种设计的优点是:

首包前失败可以被后端内部消化
前端不会收到中间供应商的错误
可以安全切换到下一个候选模型
取消句柄最终对应真正接管输出的模型
可以记录 TTFT 等首包耗时指标

它的边界也很清晰:

只处理首包前故障转移
不尝试处理首包后的跨模型续写
需要注意桥接器里的并发安全
失败后要及时 cancel 当前供应商请求

对于本地部署模型,尤其是 Ollama、vLLM 这类容易受 GPU 显存、队列压力、进程稳定性影响的推理服务,首包监测是流式调用里非常实用的一层保护。

摘要

流式大模型调用中,HTTP 成功并不代表模型已经真正开始输出。对于 Ollama、vLLM 等本地模型,服务可能在生成首个 token 前因 OOM 或显存压力崩溃。本文介绍一种首包监测设计:用桥接回调缓存首包前事件,路由层在返回取消句柄前等待首包结果,成功才放行,失败则取消当前流并切换下一个模型,从而实现前端无感的流式故障转移。