微服务延迟与资源成本的取舍
接入检索与大模型之后,微服务链路多了几个耗时和资源特征完全不同的阶段。网关与鉴权仍可能在毫秒级完成,向量检索受网络和索引影响,模型生成则会长时间占用连接与推理资源。把它们合成一个平均响应时间,很难判断应该扩容、限流还是减少输入。
延迟与成本的取舍,要从请求路径和账单来源分别拆开。一次压测、一个 CPU 利用率,或者模型供应商给出的 Token 单价,都不足以支持架构结论。
先定义用户真正等待的时间
流式生成至少有三种时间:请求进入到首个可见结果的时间、相邻输出之间的间隔,以及任务完整结束的时间。用户可能在首段内容出现后就能开始阅读,但连接与推理资源仍要等到生成完成才释放。因此,首 Token 快不等于总成本低,总耗时短也不代表输出过程流畅。
RAG 链路可以继续拆成鉴权、查询改写、Embedding、向量检索、重排、上下文组装、模型排队、Prefill 和逐步生成。每段记录开始、结束、错误与取消状态,并用同一个请求标识串联。短请求与长上下文任务分组统计,避免一批简单任务把复杂任务的长尾藏起来。
成本则按资源来源拆分:外部 API 的输入与输出 Token、本地 GPU 占用时间、CPU 检索与重排、缓存和网络。最终可以看“完成一项有效任务”的资源,而不是只看每次调用费用。无效重试、用户取消后仍继续的生成、校验失败的输出,都消耗资源却没有产生完成结果。
WebFlux 中先隔离阻塞调用
使用 WebFlux 不会让同步客户端自动变成非阻塞。如果向量库 SDK 在 Netty EventLoop 上等待网络,少量慢请求就可能阻塞其他连接。下面的写法把现有同步检索临时移到有边界的调度器,再接流式模型客户端。
@RestController @RequestMapping("/ai/chat") public class RagChatController { private final VectorStoreService vectorStore; private final LlmStreamClient llmClient; private final Scheduler retrievalScheduler; public RagChatController( VectorStoreService vectorStore, LlmStreamClient llmClient) { this.vectorStore = vectorStore; this.llmClient = llmClient; this.retrievalScheduler = Schedulers.newBoundedElastic( 16, 200, "rag-retrieval"); } @PostMapping(value = "/query", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> handle(@RequestBody QueryRequest request) { return Mono.fromCallable(() -> vectorStore.search(request.prompt())) .subscribeOn(retrievalScheduler) .timeout(Duration.ofSeconds(2)) .flatMapMany(documents -> { String prompt = PromptBuilder.build(documents, request.prompt()); return llmClient.streamOutput(prompt); }) .doOnCancel(() -> llmClient.cancel(request.requestId())); } }线程数、队列和超时只是示例,需要按同步客户端连接池与真实负载验证。timeout能停止 Reactor 链继续等待,却不保证底层阻塞调用立即终止;SDK 若提供真正的异步与取消接口,应优先使用。调度器还要随应用关闭,队列满时明确拒绝,不能退回 EventLoop 执行。
取消也要贯穿整个链路。浏览器断开 SSE 后,网关、业务服务和模型客户端都应停止后续工作。只关闭最外层响应,后台生成继续运行,用户看不到结果,账单和 GPU 占用却不会停止。
检索缓存先解决一致性与隔离
查询缓存可以减少重复 Embedding 和检索,但键不能只有原始问题。租户、用户可见范围、索引版本、过滤条件、Embedding 模型和重排策略都会改变结果。缺少任一维度,都可能返回过期内容,甚至跨越访问边界。
缓存内容还可能包含文档片段,应按原数据权限保护并设置失效方式。知识库更新后,是主动删除相关键,还是通过索引版本自然切换,要在设计中明确。命中缓存只省掉前置阶段,不能用它解释模型生成的全部延迟。
检索超时后“无上下文直接问模型”也不是通用降级。如果产品承诺基于知识库回答,这样做可能生成没有依据的内容。更稳妥的选择是返回检索暂不可用,或只展示明确标记的通用信息。降级是否允许由业务语义决定,不能藏在exceptionally里静默发生。
上下文裁剪要保留回答依据
输入变长通常会增加 Prefill 工作与显存占用,但具体关系受模型架构、推理实现、硬件和批处理影响,不宜直接写成固定比例。应使用当前模型和引擎做基准测试,同时记录输入长度、并发、批处理策略与输出上限。
裁剪不能只按相似度阈值丢文档。先去除重复片段,再按问题需要保留不同来源,给系统提示、用户问题和检索内容分别设置预算。超过预算时记录哪些文档被舍弃,回答端才能正确说明证据范围。
Top K 也不是越小越省。K 太大增加重排与上下文成本,太小可能漏掉必要证据。用带人工判断或明确答案的验证集比较召回、回答依据和延迟,再确定不同任务的范围。不要拿单一硬件上的一次结果制作通用性能表。
批处理和连接池都要防止隐藏排队
动态批处理能提高 GPU 利用,但会让请求等待凑批。交互式任务关注首 Token,离线摘要则可以接受更长等待换取吞吐,两者不应进入同一队列。至少按任务时限、输入规模和输出上限分组,避免长任务阻塞短任务。
网关连接池也应有边界。弹性、无明确上限的池会把压力推到下游;过小的池又会在网关制造排队。先确定在哪一层排队,并同时观察等待时间与连接占用。流式接口的超时要区分连接建立、长时间无数据和任务总时限,简单把全局响应超时调大,会让异常连接停留更久。
模型路由根据可信策略,不相信客户端标签
不同任务使用不同模型可以控制资源,但路由依据必须来自服务端已验证的用户权限和任务分类。客户端自带的X-User-Tier或意图头可以伪造,不能直接决定高成本集群。
路由策略还要验证能力兼容。轻量模型是否支持目标语言、结构化输出和所需上下文,备用供应商是否遵守相同数据边界,都应在上线前测试。切换模型会改变结果,不能作为完全静默的网络重试。
可以先把路由结果当作一份决策记录:任务类别、模型版本、预算、原因和允许的降级路径。网关负责执行已经确定的目标,不在过滤器中临时从用户文本猜意图。策略变化时使用同一验证集回放,检查质量、延迟和成本,而不是只看账单下降。
用同一批任务寻找可接受区间
取舍实验一次只调整一个主要条件,例如并发、输入预算、Top K 或模型规格。使用相同任务集,记录首 Token、完成时间、正确性、取消、错误和单位完成任务资源。分别报告冷缓存与热缓存,明确样本范围和环境。
最终结果通常不是一个“最优数字”,而是几个服务等级:交互请求限制排队与输出长度,后台任务允许批处理,证据不足的任务拒绝自动降级。每档写清适用任务、容量边界和用户提示。
微服务调优的重点,是让资源花在能完成的任务上。把阻塞调用移出事件循环、取消失效任务、限制隐藏队列,再根据验证集调整上下文与模型,延迟和成本才会成为可以解释的工程选择,而不是两张互相矛盾的看板。