Agent实战 #02:一个 Java Agent 从 Demo 到上线,我补了这 5 层工程化能力
引言Code Review Agent——一个基于 Spring AI Alibaba 构建的代码审查智能体。输入一段代码 diff它能识别潜在缺陷、风格问题、安全隐患并给出结构化建议。技术栈看起来不复杂Spring AI DashScope Servlet 流式透传 MySQL 存储审查记录。看起来就是调一次 LLM API。但真正把它从 demo 推到生产之后发现调一次 LLM和一个能稳定运行的 Agent 服务之间差了至少 5 层工程化能力链路分层——请求从前端到模型之间经历了 4 层网关每层都有明确职责流式透传——SSE 长连接在多层网关间的稳定传输是独立要解决的问题结构化 Prompt——不是一段话而是 6 层结构化模板覆盖角色、边界、安全模型通道路由——同一个供应商提供了两种调用方式需要根据模型特性动态选择运行时保障——可观测、可恢复、可调试三层 ChatModel 装饰器缺一不可这 5 层能力框架不会帮你自动补齐需要工程侧自己搭。下面逐一拆解包括每一层的设计决策、踩过的坑、以及代码实现。先看一下完整的请求链路前端 ReviewStreamPanel.vue → NginxTLS 终止 静态资源 反向代理 → dream-saas-gateway:9001SaaS 网关租户路由 认证鉴权 → dream-ai-gateway:8090AI 网关模型路由 调用配额 → code-review:8095Agent RuntimePrompt 组装 工具调用 → DashScope / OpenAILLM 推理2~30s → MySQL review_record审查记录落库6 个节点4 层网关/服务每一层都有独立职责。这不是过度设计是生产环境的务实选择。第 1 章请求链路不是你想的那么简单为什么要拆 4 层很多人直觉上认为 Agent 服务就是一个 Spring Boot 应用前端直连就行。Demo 阶段确实可以这么干——一个 Controller 接收请求调一次 LLM返回结果完事。但生产环境会迅速暴露几个问题租户隔离。不同租户的 AI 调用配额不同免费版每天 10 次专业版不限量模型权限也不同基础版只能用 qwen-turbo旗舰版能用 qwen-max。这些逻辑放在 Agent Runtime 里做那是业务逻辑和治理逻辑混在一起迟早失控。模型路由。同一个 AI 网关后面可能挂着通义千问、文心一言、GPT-4o 多个供应商。前端不关心用的是哪个模型只需要传一个模型 IDAI 网关负责路由到对应的供应商。服务治理。Agent Runtime 需要独立部署、独立扩缩容。一次全员代码审查的高峰期code-review 服务可能需要 10 个 Pod而用户管理服务只需要 2 个。混在一起部署扩缩容粒度不匹配。安全边界。认证鉴权在边缘网关完成内部服务之间走信任网络通信。Agent Runtime 不需要关心 JWT 怎么验、租户信息怎么解析网关已经处理好了。于是链路变成了 4 层层服务端口核心职责边缘网关dream-saas-gateway9001租户路由、认证鉴权、全局限流、IP 白名单AI 网关dream-ai-gateway8090模型路由、通道路由、AI 调用配额、用量统计Agent Runtimecode-review8095Agent 业务逻辑、Prompt 组装、工具调用、结果存储LLM ProviderDashScope/OpenAI443模型推理这种边缘网关 领域服务的分层架构在大厂 AI 平台中很常见。APISIX 做边缘、Kong 做 API 管理、BFF 做业务聚合、Agent Runtime 做推理编排——名字不同思路一致。延迟不是问题每跳一层增加 15ms 延迟4 层加起来 420ms。但 LLM 一次推理的响应时间是 2~30 秒取决于模型大小和输入长度这 20ms 在网络延迟的噪声中完全不可见。架构分层带来的隔离性、可观测性、独立部署能力远比这点延迟重要。实际上如果不用分层架构Agent Runtime 内部也需要自己做认证、限流、路由、用量统计——这些代码迟早会膨胀成一个隐形的内部网关。不如一开始就拆清楚。排障必须 traceId 贯穿4 层链路意味着请求在每一跳都可能丢失上下文。如果某一次审查出了问题——模型返回了错误结果或者响应超时——没有 traceId 贯穿排查就是大海捞针。需要在第 1 层网关生成 traceId后续每一层透传并写入日志。项目中使用TracingMdcBridge将 Micrometer Tracing 的 Span 信息注入 MDCMapped Diagnostic Context确保每一层日志都带上同一个 traceId。日志检索时用 traceId 过滤一条链路的所有节点一目了然。RestController RequestMapping(/api/v1/review) public class ReviewStreamController { Autowired private AgentUpstreamStreamProxyController streamProxy; PostMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxServerSentEventString streamReview( RequestBody ReviewRequest request, RequestHeader(X-Trace-Id) String traceId) { TracingMdcBridge.inject(traceId); log.info(Review started, model{}, repo{}, request.getModelId(), request.getRepo()); return streamProxy.proxy(/agent/code-review/stream, request) .map(chunk - ServerSentEvent.Stringbuilder() .event(review) .data(chunk) .build()) .doFinally(signal - log.info(Review finished, signal{}, signal)) .doOnError(e - log.error([trace{}] stream error, traceId, e)); } }这个接口本身不复杂但它承载了三个职责traceId 注入、SSE 事件封装、生命周期日志。流式透传的具体实现是下一章的重点。第 2 章SSE 流式是最容易翻车的问题出在哪SSEServer-Sent Events是 LLM 流式输出的标准协议。服务端持续推送 token前端逐字显示用户体验远好于等 30 秒突然全出来。但 SSE 在多层代理架构中的稳定性是一个独立的工程问题。Spring Cloud Gateway 和 OpenFeign 都是为标准的请求-响应模式设计的。当上游返回text/event-stream时它们的行为会变得不可预测Gateway 默认缓冲响应。Gateway 底层是 Netty会将上游响应攒到一定大小再下发。SSE 数据被攒到一块再推给前端前端看到的是等半天突然全出来流式效果完全丧失。Feign 超时和长连接冲突。LLM 推理慢的时候比如 20 秒Feign 默认超时可能只有 5 秒直接报超时错误。调大超时又会影响其他短接口的资源占用。Nginx 默认开启 proxy_buffering。即使应用层禁用了缓冲Nginx 作为反向代理也会缓冲响应导致 SSE 数据在 Nginx 层被攒住。这些问题在 Demo 阶段很难暴露——本地直连 Agent 服务数据量小、响应快一切正常。但上了生产、经过多层网关一定会遇到。解法专用透传 Controller项目中的做法是写一个专门的AgentUpstreamStreamProxyController绕过通用网关组件用 Java 11 原生 HttpClient 手动做流式透传RestController public class AgentUpstreamStreamProxyController { private final HttpClient httpClient HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); Value(${agent.upstream.base-url}) private String baseUrl; PostMapping(value /proxy/**, produces text/event-stream) public void proxy(HttpServletRequest request, HttpServletResponse response) throws Exception { // 禁用所有层级的缓冲 response.setHeader(Cache-Control, no-cache); response.setHeader(X-Accel-Buffering, no); response.setHeader(Connection, keep-alive); response.setBufferSize(0); // 构建上游请求 String path request.getRequestURI().replaceFirst(/proxy, ); String query request.getQueryString(); String upstreamUrl baseUrl path (query ! null ? ? query : ); HttpRequest upstreamReq HttpRequest.newBuilder() .uri(URI.create(upstreamUrl)) .timeout(Duration.ofSeconds(120)) // LLM 推理超时设大 .POST(HttpRequest.BodyPublishers.ofByteArray(readBody(request))) .header(Content-Type, application/json) .header(Accept, text/event-stream) .build(); // 发送请求获取流式响应 HttpResponseInputStream upstream httpClient.send(upstreamReq, HttpResponse.BodyHandlers.ofInputStream()); // 8KB 缓冲区循环读取并立即刷出 byte[] buffer new byte[8192]; try (InputStream in upstream.body(); OutputStream out response.getOutputStream()) { int n; while ((n in.read(buffer)) ! -1) { out.write(buffer, 0, n); out.flush(); } } catch (Exception e) { log.error(Upstream stream error, e); response.sendError(502, Bad Gateway); } } }关键配置逐项解释配置项作用为什么需要Cache-Control: no-cache禁止浏览器和中间代理缓存 SSE 响应SSE 是实时流缓存会导致数据错乱X-Accel-Buffering: no告诉 Nginx 不要缓冲这个响应Nginx 默认开启 proxy_buffering会攒住 SSE 数据response.setBufferSize(0)Servlet 容器层面禁用输出缓冲Tomcat 默认有 8KB 输出缓冲区8KB buffer read→write→flush读到多少数据就立即推给客户端核心逻辑不攒数据每次 read 后立即 flushtimeout(120s)HTTP 客户端超时设为 120 秒LLM 推理可能需要 30 秒以上这套方案的核心思想是SSE 流式响应是一条数据流不是一个完整响应。任何试图等完整响应再处理的组件都会破坏流式体验。专用透传 Controller 的价值在于绕过了所有可能缓冲数据的中间层从上游到客户端建立一条数据管道。这是务实妥协不是过度设计有人会说为什么不上 WebFlux。WebFlux 确实天然支持响应式流处理 SSE 更优雅。但项目主体是 Spring MVCServlet 栈为了一个 SSE 透传接口引入响应式编程模型改造成本太高且团队需要同时维护两种编程范式。用底层 HttpClient 手动透传代码量不到 50 行行为可控排查容易是性价比最高的方案。在工程实践中最佳方案不一定是技术上最优雅的方案而是在当前约束下代价最小的方案。第 3 章Prompt 不是写一段话就行生产环境的 Prompt 挑战大多数人的 Prompt 长这样你是一个代码审查专家请审查以下代码并给出建议。 代码{code}Demo 够了。生产环境会暴露一系列问题LLM 越界用户输入帮我写一首诗Agent 真的写了一首诗而不是拒绝工具调用混乱该调用代码分析工具时不调不该调的时候乱调输出格式不稳定有时返回 JSON有时返回 Markdown前端解析失败安全问题恶意输入让 Agent 执行了不应该执行的操作Prompt 改了就忘了改了 Prompt 导致审查结果变化但没人知道是 Prompt 的问题这些问题的根源是Prompt 没有结构化没有版本管理没有工程化治理。6 层结构化 Prompt 设计项目中将 Prompt 抽象为一个PromptTemplate接口分为 6 个层次。每一层解决一个具体问题public interface PromptTemplate { // L1: 角色定义 —— 你是谁明确 Agent 的身份和专注领域 String role(); // L2: 能力边界 —— 能做什么 不能做什么显式约束行为范围 ListString canDo(); ListString cannotDo(); // L3: 工具定义 —— 每个工具的 When to Use / When NOT to Use ListToolDefinition tools(); // L4: 交互规范 —— 输出格式、置信度要求、语气风格 String interactionRules(); // L5: 安全边界 —— 分级控制L1拒绝 / L2谨慎 / L3正常 PromptSafetyLevel safetyLevel(); OptionalString safetySupplement(); // L6: 上下文注入 —— 运行时变量用户信息、代码 diff、仓库元信息等 OptionalString contextBlock(); // 元数据 —— 版本号、描述、最后修改时间 PromptMetadata metadata(); // 组装成最终 Prompt 字符串 default String assemble() { StringBuilder sb new StringBuilder(); sb.append(## Role\n).append(role()).append(\n\n); sb.append(## Capabilities\n); sb.append(### Can Do\n); canDo().forEach(c - sb.append(- ).append(c).append(\n)); sb.append(### Cannot Do\n); cannotDo().forEach(c - sb.append(- ).append(c).append(\n\n)); if (!tools().isEmpty()) { sb.append(## Tools\n); tools().forEach(t - sb.append(t.toPrompt()).append(\n)); } sb.append(## Interaction Rules\n) .append(interactionRules()).append(\n\n); sb.append(## Safety Level: ).append(safetyLevel()).append(\n); safetySupplement().ifPresent(s - sb.append(s).append(\n\n)); contextBlock().ifPresent(ctx - sb.append(## Context\n).append(ctx).append(\n)); return sb.toString(); } }每一层解决什么问题层级职责解决的具体问题L1 角色定义明确 Agent 身份和专注领域减少幻觉防止 Agent 跑题到无关领域L2 能力边界显式列出能做什么、不能做什么防止越界用户问非代码问题时能正确拒答L3 工具定义每个工具附带使用条件和不使用条件提高工具调用精准度减少误调用和漏调用L4 交互规范规定输出 JSON schema、置信度要求、语气输出格式稳定可解析审查结果有一致的质量基线L5 安全边界分级L1 拒绝 / L2 谨慎 / L3 正常安全可控高危操作需二次确认L6 上下文注入运行时变量diff 内容、仓库元信息Prompt 模板与数据分离模板可复用可版本管理工具定义的细节工具定义不是简单地给 LLM 一个 function description。每个ToolDefinition包含name工具名称如analyze_code_complexitydescription功能描述whenToUse什么场景下应该调用这个工具whenNotToUse什么场景下不应该调用parameters参数定义JSON SchemawhenToUse和whenNotToUse是减少工具误调用的关键。经验上只给 description 不加使用条件LLM 的工具调用准确率大约在 70%80%加上明确的使用条件后可以提升到 90% 以上。在生产环境中这 10%20% 的提升就是能用和不能用的区别。Prompt 版本管理PromptMetadata记录了版本号、描述、最后修改时间、修改人。每次 Prompt 修改都要更新版本号。配合审查记录可以回溯某次审查结果为什么变了——大概率是 Prompt 版本变了而不是模型变了。这种可追溯性在生产环境中极其重要尤其是当客户问为什么上次和这次的审查结果不一样时。第 4 章模型调用要选通道两个配置不是接了两家供应商打开项目配置文件会同时看到两段配置spring: ai: openai: base-url: https://dashscope.aliyuncs.com/compatible-mode api-key: ${DASHSCOPE_API_KEY} model: qwen-max dashscope: api-key: ${DASHSCOPE_API_KEY} model: qwen-turbo第一反应是接了两个供应商。仔细看 base-url——两个都是dashscope.aliyuncs.com。实际都是通义千问区别在于调用方式不同。两种通道对比维度OpenAI 兼容模式DashScope 原生 SDK协议/v1/chat/completionsOpenAI 标准百炼原生 REST / Java SDK模型范围通义系列主流模型通义全系列 独有模型优势通用协议切换供应商成本低生态工具兼容深度集成支持通义独有特性多模态、插件等劣势通义独有特性可能不兼容绑定供应商迁移成本高流式表现标准 SSE兼容性好部分模型的流式返回格式不完全一致适用模型qwen-max、qwen-plus 等主流文本模型qwen-vl-max视觉、qwen-audio语音等DashScopeModelChannelResolver按模型选通道Component public class DashScopeModelChannelResolver { private final OpenAiChatModel openAiChatModel; private final DashScopeChatModel dashScopeChatModel; // 模型 → 通道路由表 private static final MapString, String CHANNEL_MAPPING Map.of( qwen-max, openai-compatible, qwen-plus, openai-compatible, qwen-turbo, dashscope-native, qwen-vl-max, dashscope-native // 多模态必须走原生 ); public ChatModel resolve(String modelId) { String channel CHANNEL_MAPPING.getOrDefault(modelId, openai-compatible); return switch (channel) { case openai-compatible - openAiChatModel; case dashscope-native - dashScopeChatModel; default - throw new UnsupportedModelException(modelId); }; } }为什么需要通道路由核心原因是不同模型在不同通道上的稳定性和能力覆盖不同。这不是理论推演是实际踩坑后的产物qwen-max走 OpenAI 兼容通道最稳定且后续如果要切换到 Azure OpenAI 或其他兼容供应商业务代码不用改qwen-vl-max视觉模型必须走 DashScope 原生通道因为多模态能力图片输入在 OpenAI 兼容模式下不完整会丢参数某些模型的原生通道流式返回格式有小差异比如data:和data:的区别需要在通道层做适配OpenAI 兼容 通用插头OpenAI 兼容模式的核心价值不是用 OpenAI 的 API而是用一个通用协议。通义千问提供了 OpenAI 兼容层意味着代码中的ChatModel调用可以在不改变业务逻辑的前提下切换到其他供应商——只要对方也实现了 OpenAI 兼容协议。这是一种防御性设计不绑定单一供应商。在当前的 AI 市场中模型能力迭代极快保持切换能力是合理的工程策略。第 5 章运行时保障不是可选的ChatModel vs ChatClient先厘清两个核心概念ChatModel底层基础接口核心方法是call(Prompt)和stream(Prompt)直接和 LLM API 交互。它是最小的抽象单元。ChatClient高层流畅 API提供链式调用.prompt().system(...).user(...).call().content()。它封装了 Prompt 构建、模型调用、结果解析等步骤。ChatClient 底层委托给 ChatModel 实现。增强逻辑应该加在哪一层答案是ChatModel 层。因为 ChatClient 最终调用 ChatModel在 ChatModel 层做增强可以覆盖所有调用路径——无论是通过 ChatClient 调用还是直接调用 ChatModel。三层 ChatModel 包装项目中对 ChatModel 做了三层装饰器Decorator每层对应一种运行时能力类名能力机制InstrumentingDebugChatModel实时调试WebSocket 推送 token 级响应到前端InstrumentingTraceChatModel分布式追踪Micrometer Tracing每次调用生成 SpanRecoveryInstrumentingChatModel故障恢复Resilience4j 重试/熔断 SelfCorrectLoop三层装饰器的嵌套顺序ChatModel model new RecoveryInstrumentingChatModel( new InstrumentingTraceChatModel( new InstrumentingDebugChatModel( dashScopeChatModel // 最底层真实的模型调用 ) ) );最外层是恢复层先保证可用性中间是追踪层记录调用链路最内层是调试层捕获实际 token 流。顺序不是随意的——恢复层在最外才能拦截所有异常追踪层在中间才能记录包括重试在内的所有调用。实时调试看见 Agent 在想什么InstrumentingDebugChatModel在流式响应过程中将每个 token 实时推送到前端 WebSocket。开发者或用户可以在前端看到 Agent 正在想什么——输出了什么思考过程、调用了什么工具、传了什么参数。这对调试 Prompt 和排查 Agent 行为极其有用。很多时候Agent 给出了错误结果的根因不在模型而在 Prompt 的工具定义部分有歧义。没有实时调试这种问题很难定位。分布式追踪让Agent 响应慢有数据支撑InstrumentingTraceChatModel为每次 LLM 调用创建一个 Micrometer Span记录模型 ID、通道类型Prompt token 数、completion token 数首 token 延迟TTFT总调用耗时是否触发重试、重试次数这些数据通过 Micrometer 导出到 Prometheus/Grafana形成 LLM 调用的可观测性面板。生产环境中Agent 响应慢不能只是一个模糊的用户反馈必须有数据支撑——是模型推理慢、还是 Prompt 太长、还是网络延迟、还是重试次数过多。故障恢复不是简单重试public class RecoveryInstrumentingChatModel implements ChatModel { private final CircuitBreaker circuitBreaker; private final Retry retry; private final SelfCorrectLoop selfCorrectLoop; private final ChatModel delegate; Override public ChatResponse call(Prompt prompt) { return circuitBreaker.executeSupplier(() - retry.executeSupplier(() - { try { return delegate.call(prompt); } catch (ModelException e) { if (e.isRetryable()) { log.warn(Model call failed, will retry: {}, e.getMessage()); throw e; // 触发 Resilience4j Retry } // 不可重试的异常 → 自我修正循环 return selfCorrectLoop.execute(prompt, e); } }) ); } }这里有两层恢复机制第一层Resilience4j 重试 熔断。网络超时、限流等可重试异常自动重试指数退避最多 3 次。如果连续失败超过阈值熔断器打开快速失败不继续浪费资源。第二层SelfCorrectLoop 自我修正。当 LLM 返回格式错误、工具调用参数错误等可恢复但不可重试的异常时将错误信息追加到 Prompt 中让 LLM 自我修正。比如 LLM 返回了不合法的 JSON把 JSON 解析错误信息和原始输出追加到 Prompt 末尾让 LLM 重新输出。这和 ReactAgent 的循环机制异曲同工——Agent 不只是调用一次模型而是在一个循环中不断修正直到结果满足要求或达到最大循环次数。生产环境的底线没有这三层保障Agent 在生产环境中就是黑盒不知道它在想什么缺调试、不知道它为什么慢缺追踪、不知道它挂了怎么办缺恢复。可观测、可恢复、可调试——这三层不是锦上添花是上线的最低门槛。结尾Agent 工程没有一家通吃的方案从 Demo 到生产这 5 层工程化能力——链路分层、流式透传、结构化 Prompt、模型通道路由、运行时保障——没有一个是从 Spring AI 框架里开箱即用的。框架给了核心抽象ChatModel、ChatClient、Tool Calling、ReactAgent但生产环境的工程细节需要自己补齐。成熟度现状维度成熟度说明单轮 LLM 调用★★★★★稳定可靠Spring AI / LangChain 均支持良好RAG★★★★☆向量检索 分块策略基本成熟评估体系仍在完善Agent 编排★★★☆☆ReactAgent / Graph 可用复杂编排场景仍需大量定制生产治理★★☆☆☆可观测性、故障恢复、成本控制缺乏统一标准协议层MCP★★★☆☆协议本身在快速发展生态工具链仍在上升期Java 做 Agent 的优势与定位Java 的优势类型安全Prompt 结构化、工具定义、配置管理都有编译期保障减少运行时错误微服务生态成熟网关、限流、链路追踪、配置中心、服务注册——都是现成的不需要重新造轮子企业级治理能力RBAC、审计、合规、多租户隔离——Java 生态有大量的基础设施可以直接复用对比 Python 生态LangChain / LangGraph 在 Agent 编排上更灵活社区更活跃新论文和新方法通常先出 Python 版本FastAPI 做 Agent 服务轻量快速但大规模微服务治理不如 Spring 生态成熟Python 社区在 AI 领域的迭代速度更快框架层面的创新往往先出现在 Python 生态没有哪个生态是最佳选择。Java 做 Agent 的价值在于当 Agent 需要嵌入一个已有的企业级系统中时这个系统可能已经有 50 个微服务、完善的 CI/CD、多租户架构Java 生态的治理能力和基础设施是最大的杠杆。不需要为了一个 Agent 重写整个技术栈。这 5 层工程化能力就是 Java 工程师从传统后端转向 AI Agent 时需要补齐的核心认知。框架在进化生态在成熟但工程化的基本功——分层、可观测、可恢复、可追溯——永远是自己的。