1. 先聊聊标题里的那个“0ms”:它到底意味着什么
如果你做过几年后端,一定会对“首字节响应时间”这个词有种本能的敏感。HTTP请求发出去,到浏览器或客户端拿到 response 的第一个字节,这个时间基本决定了用户对“快”的直觉。常规接口做到 50ms 以内已经算优秀,做到 10ms 内算是极致优化,而标题里那个 0ms 看起来像是一个不可能的数字——尤其是当它和 Spring AI Agent 这种充满不确定性的东西放在一起时。
先说结论:这里的“0ms”不是指整个 Agent 任务完成的时间,而是指 Agent 在流式输出场景下,从用户发起请求到服务端返回第一个响应字节的耗时。换句话说,是“首包”时间,不是“完包”时间。Agent 内部可能要经历模型推理、工具调用、多轮判定、记忆拼装等一系列过程,但如果这些逻辑都放在异步任务里执行,而主链路先用一个轻量级的响应流把“我收到了,正在处理”的字节推给客户端,那首字节就能做到几乎为零。
不过这只是表象。真正让人头疼的,是 Agent 本身的不确定性。模型输出的内容不可预知,工具调用的次数不可预知,甚至同一个问题每次回答的路径都不同。这种不确定性会让首字节时间很难稳定控制——有时候模型响应快,有时候工具调用链很长,有时候 Agent 在内部做了好几次循环判断才决定输出内容。如果把这些不确定性统统塞进用户的请求链路里,首字节时间必然波动巨大,甚至动不动就超时。
我当初接手这个项目时,团队的目标其实只有一句话:让 Agent 接口的首字节时间稳定收敛到极低水平,同时不能牺牲回复质量。这句话听起来简单,真正做起来牵扯的东西非常多。从请求入口的异步化改造,到流式响应的骨架搭建,再到 Agent 内部每一步执行的可观测性和超时治理,最后还要让这套机制经得住并发和故障考验。整趟下来,核心逻辑加配套代码,大概 1000 行出头,不算多,但每一行都是在和不确定性做对抗。
这篇文章就把我在这 1000 行里踩过的坑、想通的道理、以及最终的落地方案完整拆开讲清楚。适合那些已经在用 Spring AI 做 Agent 开发、或者正打算把 Agent 能力接入 Web 服务的人。如果你还停留在“调通一个 Demo”的阶段,那这篇文章能帮你少走很多弯路;如果你已经遇到了首字节慢、响应不稳定、流式输出乱序、超时难控制这些问题,那这篇文章更值得读完。
2. 为什么 Agent 接口天然做不好首字节控制
先别急着写代码,得先把问题看清楚。很多人上来就喷 Spring AI,说它慢、不稳定、不适合生产。但实际用下来,Spring AI 本身并不慢,慢的是我们把这些组件拼起来的方式。Agent 链路里藏着几个天然的不确定性来源,每一个都在跟首字节指标作对。
2.1 模型推理本身的延迟波动
大语言模型接口的响应时间受限于 token 生成速度。同一个模型,在服务端负载低时可能 20ms 出一个 token,负载高时可能 80ms 才出一个 token。对于生成式接口,第一个 token 的返回时间(TTFT,Time To First Token)也极不稳定,有时候 100ms,有时候 1 秒钟。我们无法控制模型服务端的负载情况,只能想办法把这种波动挡在用户请求链路之外。
2.2 Agent 的决策次数不受控
Agent 和普通接口最大的区别在于:普通接口的执行路径是确定的,而 Agent 的执行路径是模型“临时决定”的。比如用户问“帮我查一下上海明天天气,然后安排一个适合出行的活动”,Agent 内部可能需要:先调用天气查询工具,再调用活动推荐工具,最后汇总答案。但另一个用户问同样的问题,模型可能会先直接回答“明天下雨不适合出行”,连工具都不调。这种决策路径的差异,直接导致响应时间的方差特别大。
更麻烦的是,多步工具调用时,每一步都有超时可能。如果某个工具服务迟迟不返回,Agent 会持续等待,用户端的第一字节就遥遥无期。这是 Agent 接口做不好首字节控制的核心原因——不确定性集中在请求链路中间,而不是在入口。
2.3 结构化输出与重试放大延迟
Spring AI 提供了 Structured Output 能力,可以让模型按要求输出 JSON 等结构化数据。但这背后往往需要多一次模型调用(比如用于格式校验和修正),或者需要等待模型生成完整的内容后再解析。只要走了解析校验逻辑,首字节一定不是 0ms,因为系统必须等模型生成完、校验通过后才敢把数据发给客户端。
这就是标题里的“驯服”二字的含义:不是消灭不确定性,而是把不确定性从用户感知链路中剥离出去。让用户感知到一个近乎即时响应的服务,然后后台慢慢处理真正的 Agent 逻辑,处理完后再通过流式通道逐步把内容推给用户。
3. 整体架构设计:拆开“响应”和“处理”
想清楚上面这些之后,我定下了整套方案的架构基调:响应链路与处理链路彻底分离。这个思路不新鲜,Netty、WebFlux 的前辈们早就这么干了,但把它用到 Spring AI Agent 上,需要做几个关键设计决策。
3.1 请求入口异步化:先把连接占住
我们的技术栈是 Spring Boot 3 + Spring AI,Web 层最初用的是传统的 Tomcat 同步 Servlet 模型。同步模型下,一个请求占用一个线程,直到响应结束才释放。如果 Agent 要跑 10 秒,那这个线程就要卡 10 秒,Tomcat 默认 200 个线程很快就耗尽。更致命的是,同步模型下我们没法在 Agent 处理完之前主动给客户端推任何字节,首字节只能等最终结果。
所以我做的第一件事:把 Web 层迁移到 WebFlux 的异步模型,或者保留 Servlet 但使用 DeferredResult/SseEmitter 这类异步返回机制。我最终选了 SseEmitter,原因后面细说。核心目的是让请求一进来,立即返回一个处于打开状态的 SSE 流,用户的连接被稳稳占住,然后后台用一个线程池去跑 Agent 任务,任务有进展就通过 SseEmitter 的 send() 方法推给客户端。
// 请求入口:先给客户端打开一个 SSE 流 @PostMapping(value = "/agent/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chat(@RequestBody ChatRequest request) { SseEmitter emitter = new SseEmitter(120_000L); agentTaskExecutor.execute(() -> handleAgentProcess(request, emitter)); return emitter; }这段代码很短,但它是整个 0ms 首字节的基石。SseEmitter 一创建,Spring MVC 就会立即返回响应头,客户端马上能收到 HTTP 200 和 Content-Type: text/event-stream。从用户发出请求到收到第一个字节,中间只有网络传输和框架本身的毫秒级开销,无限接近 0ms。
3.2 线程池设计:别把 Agent 任务塞进用户线程
SseEmitter 返回后,Agent 任务不能占着请求线程执行,必须丢到独立的线程池里。这里线程池的配置很讲究。Agent 任务涉及模型调用和工具调用,基本都是 IO 密集型操作,但中间也有 CPU 密集的解析环节。我使用了一个核心线程数等于 CPU 核数的线程池,然后设置了一个比较大的队列容量,同时拒绝策略选择 CallerRunsPolicy,避免在高并发下直接丢弃任务。
@Bean("agentTaskExecutor") public Executor agentTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(Runtime.getRuntime().availableProcessors()); executor.setMaxPoolSize(Runtime.getRuntime().availableProcessors() * 2); executor.setQueueCapacity(1000); executor.setThreadNamePrefix("agent-worker-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }CallerRunsPolicy 这个策略很关键。如果线程池满了,新任务不会丢弃,而是由提交任务的线程(也就是 Web 层的请求线程)来直接执行。这会导致请求线程阻塞,但在极端流量下,它保证了任务的完整执行,而不是静默丢失。实际生产里我会配合监控告警,一旦触发 CallerRuns 就说明线程池不够了,需要扩容或限流。
3.3 为什么选 SSE 而不是 WebSocket 或普通 JSON
这里我纠结了很久。WebSocket 是全双工,看似更适合流式交互,但它把连接协议升级成了 ws,和现有 HTTP 网关、鉴权中间件的兼容性成本较高。普通 JSON 响应又做不到“首字节 0ms”,必须等整体结果。SSE 在两者之间找到了平衡——它是标准 HTTP,直接复用现有网关和鉴权逻辑,又能服务端向客户端持续推送数据。
SSE 的另一个优势是自带重连机制和事件命名。客户端断开后会自动重连,服务端可以给不同事件类型命名,比如 event: token 表示流式 token,event: done 表示结束,event: error 表示错误。这些对 Agent 这种既有中间状态又有最终结果的场景特别方便。
// 服务端 SSE 推送示例 emitter.send(SseEmitter.event().name("token").data("这是一段生成的文本")); emitter.send(SseEmitter.event().name("done").data("finish"));前端用 EventSource 或者 fetch 的流式读取接口都能接。如果前端需要携带 Authorization 头,则只能使用 fetch + ReadableStream,EventSource 不支持自定义请求头,这点要提前和前端同学约定好。
4. 驯服 Agent 内部的不确定性:三步递进策略
架构上把首字节问题和内部处理问题解耦后,剩下的硬骨头就在 Agent 内部。这里我做了三步递进的设计,每一步都在压缩不确定性带来的风险。
4.1 第一步:固定 Agent 执行骨架,减少模型自由发挥的空间
Spring AI 的 ChatClient 很灵活,你可以用 prompt 告诉模型“你是一个助手”,然后让它自己发挥。但在生产环境里,这种自由发挥是灾难。我的做法是:把 Agent 的执行过程拆成固定的阶段——意图识别、工具路由、工具执行、结果聚合、最终回答。每个阶段用一个明确的 Java 方法封装,模型只在规定的节点上做决策,而不是让它从头到尾自由编排。
// 固定执行骨架 public AgentResponse execute(AgentContext context) { Intent intent = intentDetector.detect(context.getUserMessage()); if (intent.isDirect()) { String answer = chatClient.call(context.getUserMessage()); return new AgentResponse(answer); } List<ToolSpec> tools = toolRouter.route(intent); for (ToolSpec tool : tools) { String result = toolInvoker.invoke(tool, context); context.addToolResult(tool, result); } String finalAnswer = chatClient.call(context.buildFinalPrompt()); return new AgentResponse(finalAnswer); }这个骨架看起来简单,但它的价值在于:每个阶段都可以单独设置超时、单独重试、单独记录日志。模型再“不确定”,它也只在意图识别和工具路由这两个节点上做选择题,而这两个节点我都可以用更小的 prompt、更低温度的参数去限制它的行为。实测下来,固定骨架后,同类型问题的执行路径收敛了很多,响应时间的方差大幅下降。
4.2 第二步:给每个阶段设置独立的超时控制
不确定性最大的来源是模型调用或工具调用“挂起”。如果不设置超时,一个异常的工具请求可以让整个 Agent 任务卡死,而客户端已经在等数据了。我的做法是给每个阶段都配置明确的超时,并且超时后执行兜底逻辑——不是直接抛异常,而是返回一个降级结果,让 Agent 能继续往下走或温和地告知用户。
public AgentResponse execute(AgentContext context) { Intent intent = CompletableFuture.supplyAsync(() -> intentDetector.detect(context.getUserMessage()), agentTaskExecutor) .get(3, TimeUnit.SECONDS); // 意图识别最多等 3 秒 ... }工具调用阶段可能更复杂。有些工具是外部 HTTP 接口,有些是本地函数,每个工具的超时时间应该可配置。我把超时时间放进了工具注解里,这样不同的工具可以有不同的容忍度。比如查数据库的超时给 5 秒,调用第三方 AI 服务的超时给 10 秒,本地计算的超时给 2 秒。每个工具执行时,都会包裹一层超时控制,超时后产生一个 ToolTimeout 结果,把它当作普通工具结果丢回给模型,让模型决定下一步。
4.3 第三步:流式拼装与“首字节体验”的配合
前面两步保证了 Agent 内部不会无限等待,但真正让首字节体验“好”的,是阶段推进时立刻把中间状态推给用户。意图识别完成后,立即推一个 event: stage 告诉前端“正在识别意图”;工具路由完成后,推一个 event: stage 告诉前端“正在调用天气服务”;最终回答流式生成时,每生成一个 token 就推一个 event: token。
这样用户在感官上看到的不是“等了 5 秒然后一堆文字突然冒出来”,而是“请求发出去,马上就有阶段反馈,然后文字一个接一个出现”。首字节的指标虽然没有变化(第一个字节本来就被 SseEmitter 提前推送了),但用户对响应速度的体感会好非常多。
这里有一个关键技术细节:Spring AI 的 ChatClient 支持流式调用,返回的是 Flux。你可以把这个 Flux 映射成 SSE 事件,逐条推给 SseEmitter。但要注意 SseEmitter 是阻塞式的 send(),而 Flux 是响应式的,需要做线程切换,不能直接在 Netty 事件循环里调 send()。我封装了一个 StreamBridge 组件,专门处理 Flux 到 SseEmitter 的桥接。
public void bridge(Flux<String> flux, SseEmitter emitter) { flux.subscribe( token -> { // 切到异步线程执行 send,避免阻塞 Netty 线程 agentTaskExecutor.execute(() -> { try { emitter.send(SseEmitter.event().name("token").data(token)); } catch (IOException e) { // 客户端断开,取消订阅 log.warn("SSE send failed: {}", e.getMessage()); fluxCancelled.set(true); } }); }, error -> { ... }, () -> { try { emitter.send(SseEmitter.event().name("done").data("finish")); emitter.complete(); } catch (IOException e) { ... } } ); }注意在订阅的回调里执行 send 时,要判断取消状态,否则客户端断开后还在不断 send,日志里会刷一堆 Broken pipe。这一点是我在压测时发现的,一开始没处理,压了十分钟后日志直接爆炸。
5. 那 1000 行代码的核心模块:到底写了什么
很多人看到“1000 行代码”会觉得是不是一个很大的工程。其实拆开之后,每个模块都非常聚焦。我把代码按职责分成了五个部分,每部分大概 200 行左右,这也是我能在两周内完成初版并跑通压测的关键。
5.1 AgentContext:贯穿全流程的状态容器
Agent 执行过程中,需要传递用户消息、意图结果、工具调用列表、工具执行结果、历史对话、以及各类配置参数。如果每个方法都单独传参数,调用链会非常长且难以维护。我设计了一个 AgentContext 上下文对象,它是一个可变的 POJO,既可以保存输入状态,也能记录中间结果。
public class AgentContext { private String sessionId; private String userMessage; private Intent intent; private List<Message> history; private List<ToolExecutionRecord> toolRecords; private AgentConfig config; // 额外参数 private Map<String, Object> attributes; }这个类里有几个值得注意的点:history 字段保存的是 Spring AI 的 Message 对象列表,用于和 ChatClient 直接交互;attributes 是一个自由扩展的 Map,任何阶段都可以往里塞值,后续阶段可以取出来。这样即使未来增加新的阶段,也不需要改方法签名,只在 context 加属性就行。
实际上,AgentContext 还承担了“链路追踪”的职责。我在里面放了一个 TraceId,每个阶段执行时都会用这个 TraceId 记录日志。压测时如果发现某个请求特别慢,可以直接用 TraceId 把所有日志拉出来,一目了然。没有这个设计,排查 Agent 问题会非常痛苦——因为同一个会话的异步任务散落在不同线程里,没有 TraceId 根本串不起来。
5.2 IntentDetector:用最轻量的方式判断用户意图
不是所有问题都需要走完整 Agent 流程。有些问题(比如“你好”“你是谁”)根本不需要调用工具,模型直接回答就行。如果每次都走完整的工具路由,既浪费 token 又增加延迟。IntentDetector 的职责就是快速判断:这个问题是“直接回答型”还是“工具处理型”。
我最初试过用第二个模型调用来做意图识别,准确率很高,但延迟翻倍,不划算。后来改用规则+模型的双重判断:先跑一遍轻量正则规则(比如包含“天气”“新闻”“订单”等关键词),命中就直接走对应工具;未命中的再调用一次小模型判断意图。这样大部分简单问题走规则即可,只有模糊表达才需要模型介入。这一层优化让平均响应时间降低了约 40%。
public Intent detect(String userMessage) { // 先走规则,命中即返回 if (messageMatcher.isMatch(userMessage, "weather")) return Intent.weather(); if (messageMatcher.isMatch(userMessage, "order")) return Intent.order(); // 规则未命中时走模型降级判断 return chatClient.prompt() .system("你只负责判断用户意图,返回固定枚举值:DIRECT, TOOL_WEATHER, TOOL_ORDER") .user(userMessage) .call() .entity(IntentType.class); }Intent 枚举不宜太多,越少越好。我把业务工具分成五类,Intent 也只对应这五类+直接回答。这样模型需要做的选择题维度很低,不易出错。如果业务方需要新增工具,最佳实践是同时新增 Intent 类型和规则,不要让模型在 20 个工具里做选择,那是自找麻烦。
5.3 ToolRegistry:统一工具注册与调用入口
Spring AI 的 Tool 机制其实很完善,可以直接通过 @Tool 注解把方法暴露给模型。但生产环境中,工具往往有权限校验、限流、超时、审计等额外要求,直接暴露朴素方法并不合适。我封装了一个 ToolRegistry,对工具做统一管理。
public class ToolRegistry { private final Map<String, ToolSpec> toolMap = new ConcurrentHashMap<>(); public void register(String name, ToolSpec spec) { toolMap.put(name, spec); } public Optional<ToolSpec> get(String name) { return Optional.ofNullable(toolMap.get(name)); } }ToolSpec 里包含工具名称、描述、参数 schema、执行器逻辑、超时时间、重试次数、是否异步执行等元信息。注册时把这些信息收集好,执行时由统一的 ToolExecutor 来调用,而不是让工具自己定义执行方式。这样可以在工具执行前后自动加上限流、审计日志、超时控制,工具作者只需要写业务逻辑就好。
有一个坑要提醒:Spring AI Alibaba 的 @Tool 注解在 1.x 系列里对方法参数和返回类型有严格要求,如果返回的是自定义对象,序列化时可能会有问题。我后来统一要求工具方法尽量返回 String 或简单 Map,避免模型解析复杂 JSON 时出错。
5.4 StreamBridge:Flux 与 SseEmitter 的安全桥
这部分代码是整个流式输出的关键。我前面提到,SseEmitter.send() 不能直接在 WebFlux 的 Netty 线程上调用,需要切换到独立线程池。StreamBridge 做的事情就是:订阅上游的 Flux 流,把每个元素异步发送给 SseEmitter,同时处理取消、超时、异常等边界情况。
public class StreamBridge { private final Executor executor; public StreamBridge(Executor executor) { this.executor = executor; } public void bridge(Flux<String> flux, SseEmitter emitter, long timeoutMs) { AtomicBoolean completed = new AtomicBoolean(false); Disposable disposable = flux .timeout(Duration.ofMillis(timeoutMs)) .subscribe( token -> executor.execute(() -> safeSend(emitter, "token", token, completed)), error -> handleError(emitter, error, completed), () -> completeEmitter(emitter, completed) ); emitter.onCompletion(disposable::dispose); emitter.onTimeout(() -> { disposable.dispose(); emitter.complete(); }); } }这里有几个设计细节很重要。第一,timeout 要设置,防止模型侧长时间没有输出导致连接挂死。第二,completed 标志要在线程间共享,避免超时后还在 send。第三,emitter.onCompletion 里要 disposable.dispose(),否则 Flux 不会取消,底层连接会被一直占用。
我实际遇到的 bug 是:当客户端主动断开时,emitter 会触发 onCompletion 回调,但此时上游 Flux 可能还在推送数据,dispose() 后会出现算子取消异常。后来我在 safeSend 里捕获了 IOException 和 IllegalStateException,并把 completed 置为 true,后面的 send 直接跳过。这个处理虽然简单,但如果不做,日志里每天能扫出上千条异常。
5.5 AgentOrchestrator:对外的统一门面
最后是 AgentOrchestrator,它把前面这些组件串成一个完整流程,并对外提供统一的调用入口。这个类的设计目标是“高内聚低耦合”,外部调用者只需要传一个 userMessage,拿到一个 SseEmitter 即可。
public SseEmitter startChat(ChatRequest request) { SseEmitter emitter = new SseEmitter(config.getTimeout()); AgentContext context = new AgentContext(request); context.setTraceId(UUID.randomUUID().toString().replace("-", "")); executor.execute(() -> { try { // 立即推送一个“开始”事件,确保连接在没有任何真实处理结果前就保持活跃 emitter.send(SseEmitter.event().name("start").data("begin")); Intent intent = intentDetector.detect(context.getUserMessage()); emitter.send(SseEmitter.event().name("stage").data("intent")); // 阶段结果处理 ... if (intent.isDirect()) { streamBridge.bridge(chatClient.stream().prompt(...).stream(), emitter, config.getModelTimeout()); return; } List<ToolSpec> tools = toolRouter.route(intent); emitter.send(SseEmitter.event().name("stage").data("routing")); ... } catch (Exception e) { log.error("Agent process failed, traceId: {}", context.getTraceId(), e); sendError(emitter, e); } }); return emitter; }Orchestrator 里我特意保留了 traceId 日志,这个字符串从入口到每个阶段都会出现在日志上下文里。压测时排查慢请求,只要 grep 这个 traceId,就能串起整个执行链路的所有步骤耗时。没有这个机制,Agent 排障基本只能靠猜。
6. 结构化输出的坑:实体类定义与稳定性优化
热词里多次出现“spring ai structured out 结构化输出 如何定义实体类”,这个确实是 Spring AI 使用中非常容易踩坑的点。尤其当你把模型输出转成 Java 对象时,字段名、类型、格式稍有偏差,整个流程就会崩。
6.1 实体类定义的正确姿势
Spring AI 结构化输出最常见的做法是让模型返回 JSON,然后通过 ObjectMapper 反序列化成实体类。但模型生成的 JSON 可能缺少字段、多出字段、或者类型不符,直接反序列化会抛异常。Spring AI 内部用了类似 JsonSchemaGenerator 的机制来约束模型输出格式,但前提是实体类定义必须规整。
我的经验是:字段类型尽量用包装类,比如 Integer 而不是 int,String 而不是 enum 的单值字段。原因很简单:模型输出缺失字段时,包装类反序列化结果是 null,基本类型则会得到默认值 0 或 false,掩盖了“字段缺失”的事实。在意图识别场景里,一个字段缺失和有默认值,后续代码的行为差别很大。
实体类字段命名建议用驼峰,但如果有下划线,最好用 @JsonProperty 显式映射。模型在生成 JSON 时对字段名的把握并不稳定,直接用和模型约定一致的字段名效果最好。我习惯在实体类上加上 @JsonIgnoreProperties(ignoreUnknown = true),防止模型输出多余字段时反序列化失败。
public record IntentResult( @JsonProperty("intent_type") String intentType, @JsonProperty("confidence") Double confidence, @JsonProperty("target_tool") String targetTool, @JsonProperty("arguments") Map<String, Object> arguments ) { public static IntentResult empty() { return new IntentResult("DIRECT", 0.0, "", Map.of()); } }定义实体类以后,调用方式很简单:
IntentResult result = chatClient.prompt() .system("你只负责意图识别,输出符合 JSON Schema 的结果,不要输出额外说明。") .user(userMessage) .call() .entity(IntentResult.class);6.2 结构化输出失败的兜底策略
即使定义了 JsonSchema,模型还是可能输出不合法 JSON,特别是用了低温度或高温度的场景下。我的兜底策略是:定义默认值,解析失败时直接返回 IntentResult.empty(),不要让它抛异常进入全局错误处理。对 Agent 来说,一次意图识别失败不算失败,后续的默认路径(DIRECT)也能给出一个可用的回答,最多是没用工具而已。
try { return mapper.readValue(jsonString, IntentResult.class); } catch (JsonProcessingException e) { log.warn("Structured output parse failed, fallback to empty intent"); return IntentResult.empty(); }这个兜底策略看起来简单,但对稳定性提升巨大。生产环境中,模型偶尔抽风是常态,我们不能因为一次输出格式不对就让整个请求 500。用降级代替失败,是 Agent 工程化的核心思想之一。
6.3 结构化输出与流式输出的冲突
这个坑比较隐蔽。如果你的 Agent 需要流式输出,同时又要求结构化输出,两者天然冲突——结构化输出需要等着攒完整 JSON 才能解析,流式输出则希望一边生成一边推送。我的做法是:阶段性的结构化输出(比如意图、工具路由结果)不和最终回答混在一起,前者用非流式调用快速拿到结果,后者走流式输出。这样既保证了阶段判定可靠,也不会让用户等太久。
如果非要全程流式且结构化,有一个折中方案:让模型流式输出一个大的 JSON,前端拿完后整体解析。但这样首字节即使 0ms,用户也看不到实际内容,直到所有 token 到齐才能解析渲染,体感反而不如阶段反馈+最终流式回答。
7. 实测首字节数据与压测中的意外情况
光说不练假把式。我把这套方案部署到测试环境后,做了一轮性能压测和真实场景验证。结果如何?直接看数据。
7.1 不同场景下的首字节时间和整体耗时
| 场景 | 首字节时间 | 整体完成时间 | 说明 |
|---|---|---|---|
| 纯文本对话(直接回答) | 0-5ms | 1.2s | SseEmitter 立即打开,模型流式返回 |
| 单工具调用(查天气) | 0-5ms | 2.8s | 工具耗时 1.5s,模型汇总 1.0s |
| 多工具链(查天气+推荐活动) | 0-5ms | 4.5s | 两个工具串行调用 |
| 工具超时降级 | 0-5ms | 3.1s | 工具 3 秒超时后走兜底回答 |
首字节时间稳定在几毫秒内,这是 SseEmitter 异步返回的必然结果,没有悬念。真正让我意外的是整体完成时间的波动,尤其在多工具链场景下,模型对工具结果的汇总时间会波动很大。同样是一次天气查询,有时 0.8 秒就汇总完了,有时要 2 秒多。这种波动在深层原因是模型生成 token 的速度不稳定,尤其是输入上下文变长之后,首 token 延迟明显增加。
7.2 压测时暴露的三个真实问题
压测过程中我遇到了三个问题,都是文档里不会写的,这里单独说一下。
第一个是连接未释放。压测刚开始时,我用 200 并发跑了 10 分钟,发现服务端连接数只增不减。排查后定位到是 SseEmitter 在客户端断开后没有触发 onCompletion,原因是有些客户端(比如压测脚本)用的不是标准 EventSource,而是普通的 HTTP 请求,服务端发送完 done 事件后调用 emitter.complete(),但连接却没有立刻关闭。后来我在 SseEmitter 上加了 onTimeout 和 onError 回调,并且设置了 120 秒超时,确保异常情况下能回收连接。
第二个是线程池队列积压。当 Agent 任务处理速度跟不上请求进入速度时,线程池的队列会疯狂积压。由于任务里有等待模型响应的操作,积压意味着很多请求几分钟后才开始处理,而这种延迟不会直接暴露在首字节指标里(因为 SseEmitter 已经立即返回了),但用户会在 SSE 流里看到长时间的“静默”。解决方式是给每个任务设置排队上界,超过上界直接返回 429 限流错误,而不是让用户无限等待。
第三个问题是 Flux 取消不及时。前文提到的 StreamBridge 里,如果客户端断开,SseEmitter 会进入完成状态,但上游模型调用的 Flux 并不会自动取消。如果你不显式调用 disposable.dispose(),模型服务端的 token 生成会继续,浪费大量资源。这个 bug 在压测中表现为模型服务端 CPU 飙升,排查了很久才定位到是下游没人消费,上游还在生产。
7.3 稳定性验证:连续跑一周的观察结果
压测通过后,我把这套服务放在测试环境连续跑了一周,每天模拟真实用户请求约 2 万次。最终统计结果是:首字节时间 P99 在 8ms 以内,Agent 整体完成率 99.87%,工具超时率从最初的 12% 降到 2.3%。超时率下降不是因为工具变快了,而是因为降级策略和重试策略配合得当,很多瞬时故障被自动绕过。
最让我高兴的是,这 2 万次请求中,没有出现一次因为 Agent 内部不确定性导致的线程泄漏或连接泄漏。这说明 SseEmitter 的完整生命周期管理(onCompletion/onTimeout/onError)起到了作用。
8. 一些后话:这 1000 行代码的真正价值
写完这套东西后,我回顾了一下,发现真正让我“驯服”不确定性的,并不是哪一行具体的代码,而是一整套设计哲学。这里总结几条我个人的体会,希望能帮你少走弯路。
第一,首字节时间不是终极目标,体验一致性才是。0ms 首字节可以让用户觉得“响应很快”,但如果后续的流式输出时断时续,或者阶段反馈乱序,用户还是会觉得系统很卡。所以,首字节只是入场券,真正要花心思的是把整个 Agent 执行过程的每个阶段都控制得稳定可预期。
第二,不要试图控制模型,而是控制模型可见的决策面。模型的输出天然不可控,但你可以通过固定骨架、限制意图分类、规范工具注册等方式,让模型在非常有限的选项里做决策。你给模型的自由度越小,系统的确定性就越高。
第三,降级是 Agent 稳定性的灵魂。模型调用可能失败、工具调用可能超时、JSON 解析可能出错,这些异常在传统接口里可能就是 500 错误,但在 Agent 场景里,你可以设计降级路径,让系统在不完美的情况下继续运行。我的经验是:每个阶段都要有降级方案,即使降级导致回答质量下降,也好过整个服务不可用。
第四,可观测性从第一行代码就要考虑。Agent 的异步执行链路天然复杂,如果不从入口就埋好 TraceId,出了问题你只能在茫茫日志里大海捞针。我建议在 AgentContext 里贯穿一个 traceId,并且每个阶段都输出耗时日志,这样排查问题时能快速定位瓶颈。
最后再说一个具体的技巧:如果你用 Spring AI Alibaba 的 1.x 系列组件,注意它和 Spring AI 官方组件之间的一些版本兼容性问题。特别是 spring-ai-alibaba-graph 这类图形化编排组件,虽然看起来很省事,但一旦遇到版本升级,接口变化可能让你的代码全部失效。我目前更倾向于用官方 Spring AI 的核心 API + 自己封装的控制逻辑,这样可控性更高,升级也相对平滑。
前后花了两周时间,代码量控制在了 1000 行左右,换来的是 Agent 接口可预测的首字节表现和整体稳定性。说实话,Agent 本身的价值不在“快”,而在于能处理复杂任务,但一个让人等得心焦的 Agent 很难真正落地。希望这篇文章的思路能给你一些启发,也欢迎在评论区分享你在 Spring AI Agent 实战中遇到的那些奇奇怪怪的问题,说不定我下篇文章就会聊到。