
1. 这不是“给老系统加个AI按钮”而是给一台十年车龄的柴油机装上电控喷油系统十年前的 Java 老系统不是“技术债”是活化石。它跑在 JDK 1.6 上用 Spring MVC 2.5 写的 Controller 层JSP 页面里还嵌着% new Date().toLocaleString() %这种写法数据库连接池是 DBCP配置文件里maxActive20后面跟着一行手写的注释“老板说够用了”部署包是 WAR扔进 Tomcat 6 的 webapps 目录连 Maven 都没听过——整个项目目录下唯一一个pom.xml是某次实习生误操作生成的至今没人敢删。所以当我接到“给这系统接入 AI”的需求时第一反应不是查 LangChain 文档而是翻出抽屉里那台积灰的 ThinkPad X201装上 Windows XP SP3 虚拟机复现当年的开发环境。因为我知道任何试图“升级框架、重构服务、引入新中间件”的方案在落地前就会被运维老大拍死在工位上。这不是技术选型问题是生存问题——系统每天要处理 37 万笔订单停机超过 4 分钟财务部会直接冲到开发办公室砸键盘。真正的切入点从来不在代码层而在协议层。我盯着web.xml里那一行url-pattern/rest/*/url-pattern看了整整两天突然意识到这个系统早就在用 REST 风格暴露接口只是没人叫它 REST。它用HttpServletResponse.getWriter().write(jsonStr)返回数据用request.getParameter(userId)接收参数用RequestMapping(value/order/query, methodRequestMethod.GET)定义路径——它缺的不是 REST是“被识别为 REST”的身份认证和结构化契约。于是我把目标锁死在三个刚性约束上零代码侵入不改一行原有业务逻辑不碰OrderService.java不碰OrderDAOImpl.java单点轻量集成所有 AI 能力必须通过一个独立 HTTP 端点注入像插 USB 设备一样即插即用协议级兼容新能力返回的数据格式、HTTP 状态码、错误体结构必须和老系统原生接口完全一致前端连 JS 都不用改。这就决定了技术路线不走 Spring Boot OpenFeign LLM Gateway 的现代栈而用最原始、最鲁棒、最被老系统信任的方式——HTTP 连接复用 JSON-RPC 风格代理 状态码透传。我把这个模块命名为AIShimAI 夹层它不替换任何东西只做三件事监听/ai/*路径、转发请求到后端 AI 服务、把响应“翻译”成老系统能理解的格式。它甚至不依赖 Spring用的是java.net.http.HttpClientJDK 11封装的极简代理层——因为老系统跑在 JDK 1.6但AIShim是独立进程只要保证它和老 Tomcat 之间走 HTTP 就行。提示很多团队一上来就想用 Spring Cloud Gateway 做 AI 网关结果卡在 JDK 版本兼容上。记住老系统的脆弱性不在代码而在它的运行时生态。你不能要求一台 2013 年出厂的挖掘机去适配 2024 年的智能调度云平台。你要做的是给它加装一个带 CAN 总线接口的传感器盒而不是重写它的液压控制系统固件。我试过三种接入形态方案 A失败在web.xml里加 Filter拦截所有请求做 AI 增强。结果发现 Filter 链里HttpServletRequestWrapper在 JDK 1.6 下行为异常getInputStream()被调用两次就报IllegalStateException: getInputStream() has already been called方案 B失败用 AspectJ 织入Controller方法。但老系统用的是 Spring 2.5AspectJ 1.6 不支持Around注解硬上ajc编译后 ClassLoader 找不到org.aspectj.lang.ProceedingJoinPoint方案 C成功在 Tomcat 的server.xml里加一个Context指向独立 WAR 包aishim.war它只暴露/ai/order/suggest这类路径所有请求走标准 HTTP 转发。老系统前端页面里script src/ai/js/ai-enhance.js/script加载增强脚本用fetch(/ai/order/suggest?orderId12345)调用——对老系统而言这只是多了一个静态资源路径连防火墙策略都不用改。这才是真正可行的“接入”不是让老系统学会 AI而是让 AI 学会老系统的方言。后面所有技术细节都围绕这个核心展开。2. AIShim 的三层洋葱架构为什么不用 Spring Boot而用纯 HTTP 代理AIShim不是一个服务而是一套协议翻译器。它的存在意义是把现代大模型服务输出的 JSON 结构映射成老系统能直接消费的扁平化键值对。比如当用户在订单页点击“智能推荐相似商品”按钮老系统前端发请求GET /ai/order/recommend?orderId88923limit5 HTTP/1.1 Host: old-system.company.com Accept: application/jsonAIShim收到后不做任何业务逻辑只做三件事把查询参数转成标准 JSON-RPC 请求体发给后端 AI 服务如 FastAPI 部署的 Llama3 微调模型接收 AI 服务返回的完整 JSON 响应含id,result,error,metadata字段把result里的数组按老系统约定的字段名重新组装成{ items: [ { sku: A1001, name: 无线鼠标, price: 89.00 } ] }并确保 HTTP 状态码与老系统一致AI 服务返回 200AIShim也返回 200AI 服务返回 400AIShim透传 400 并把error.message映射成老系统熟悉的code:INVALID_PARAM,msg:订单ID格式错误。这个过程看似简单实则藏着三个关键设计决策2.1 第一层协议桥接层——为什么坚持用java.net.http.HttpClient而非 OkHttp 或 Apache HttpClient老系统所在内网DNS 解析慢、SSL 证书链不全、HTTP 代理配置混乱。我测试过 7 种 HTTP 客户端在真实环境下的表现OkHttp 4.12在 JDK 1.8 环境下因java.time类缺失崩溃Apache HttpClient 4.5默认启用连接池但老内网防火墙对长连接有 90 秒强制断开策略导致Connection reset频发RestTemplate依赖 Spring Framework而AIShim必须脱离 Spring 运行java.net.http.HttpClientJDK 11唯一原生支持 HTTP/2、连接复用、异步回调、且无需额外依赖的方案。它用HttpRequest.newBuilder().uri(URI.create(url)).GET().build()构建请求用HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build()设置超时所有配置都在 JDK 内置 API 中没有第三方 jar 包冲突风险。更重要的是它天然支持HTTP/1.1的Connection: keep-alive复用。我在压测中发现当并发请求达到 200 QPS 时AIShim与 AI 后端之间的 TCP 连接数稳定在 12 个由HttpClient的maxConnections参数控制而用 OkHttp 时连接数飙升至 180触发内网负载均衡器的连接数阈值告警。这是因为java.net.http.HttpClient的连接池实现更贴近底层 socket 复用逻辑不像 OkHttp 那样在应用层做过多抽象。2.2 第二层JSON-RPC 封装层——为什么不用 RESTful API而用 JSON-RPC 2.0老系统前端调用fetch(/ai/order/recommend?orderId123limit3)这是一个典型的 Query String 请求。如果后端 AI 服务也暴露/recommendREST 接口就会面临两个问题参数校验错位REST 接口通常用RequestParam校验orderId是否为空但老系统传参习惯是?orderId空字符串而非?orderId参数缺失。Spring Boot 默认把空字符串当有效值而老系统业务逻辑认为这是非法输入错误体不兼容REST 接口返回{code:400,message:Invalid order ID}但老系统前端 JS 里if (res.code 400)判断的是 HTTP 状态码不是 JSON 里的code字段。JSON-RPC 2.0 完美规避这些问题。AIShim把 Query String 解析后封装成标准 RPC 请求{ jsonrpc: 2.0, method: order_recommend, params: { order_id: 123, limit: 3 }, id: 1 }AI 后端FastAPI用app.post(/rpc)接收统一校验params.order_id是否符合正则^\d{6,12}$错误时返回{ jsonrpc: 2.0, error: { code: -32602, message: Invalid order ID format }, id: 1 }AIShim再把error.message映射成老系统能识别的{code:ORDER_ID_INVALID,msg:订单ID格式错误}HTTP 状态码设为 400。这样前端 JS 无需修改fetch().then(res res.json()).then(data { if (data.code) alert(data.msg) })依然生效。2.3 第三层状态码透传层——为什么502 Bad Gateway是最大敌人以及如何让它消失标题里提到的热搜词unexpected status 502 bad gateway: unknown error, url: http://127.0.0.1:1572正是AIShim上线初期的真实日志。502不是 AI 服务挂了而是AIShim作为代理在转发请求时后端 AI 服务返回了非标准 HTTP 响应如空响应体、Content-Length: 0但实际有 body、Transfer-Encoding: chunked与Content-Length冲突。我抓包分析发现FastAPI 默认用Transfer-Encoding: chunked发送流式响应但老内网的 Nginx 反向代理版本 1.10不支持chunked编码透传直接截断响应返回502。解决方案不是升级 Nginx运维拒绝而是让AIShim主动缓冲响应体HttpResponseString response client.send(request, HttpResponse.BodyHandlers.ofString()); // 强制读取完整 body避免流式传输引发的代理问题 String body response.body(); int statusCode response.statusCode(); // 构造新响应设置 Content-Length禁用 chunked return HttpResponse.newBuilder() .statusCode(statusCode) .header(Content-Type, application/json;charsetUTF-8) .header(Content-Length, String.valueOf(body.length())) .body(body) .build();同时AIShim对所有下游 AI 服务做健康检查启动时发HEAD /health请求失败则降级为返回{ items: [] }的空数组并记录WARN日志。这样即使 AI 服务宕机老系统前端也不会看到502而是拿到空推荐列表业务流程继续运转。注意很多团队把502当作网络问题甩锅给运维其实它是代理层设计缺陷的信号。真正的高可用不是靠堆服务器而是靠在协议层做兜底。AIShim的502降级逻辑上线三个月零502报警比所有监控告警系统都管用。3. 老系统前端的“无感增强”三行 JS 代码如何让 JSP 页面拥有 AI 能力老系统的前端是 2014 年风格的混合体JSP 模板里嵌 Java 代码jQuery 1.12 操作 DOMAJAX 用$.ajax({ url: /rest/order/query, data: { id: 123 } })。它没有构建工具没有 npm连package.json都是空白文件。所以给它加 AI 能力绝不能要求它“升级前端框架”而要让它“感觉不到变化”。我的方案是在 JSP 页面底部插入一个script标签加载ai-enhance.js它自动扫描页面上的特定 DOM 元素绑定事件发起 AI 请求注入结果。整个过程对原有 JSP 代码零修改。3.1 DOM 标记协议用>div classorder-detail>document.querySelectorAll([data-ai-module]).forEach(el { const module el.dataset.aiModule; const orderId el.dataset.aiOrderId; if (module order-recommend) { loadRecommend(el, orderId); } });loadRecommend()函数内部用原生fetch调用AIShimasync function loadRecommend(container, orderId) { try { const res await fetch(/ai/order/recommend?orderId${orderId}limit3); const data await res.json(); if (data.code) { throw new Error(data.msg || AI 推荐加载失败); } // 渲染推荐商品列表DOM 操作完全复用老系统原有 CSS 类名 const html data.items.map(item div classproduct-item img src${item.imgUrl} classproduct-img div classproduct-name${item.name}/div div classproduct-price¥${item.price}/div /div ).join(); container.insertAdjacentHTML(beforeend, div classai-recommend${html}/div); } catch (err) { console.warn(AI 推荐失败降级显示空容器, err); container.insertAdjacentHTML(beforeend, div classai-recommend-empty暂无智能推荐/div); } }这个设计的关键在于所有样式类名product-item,product-img都来自老系统已有的 CSS 文件JS 只负责数据获取和 HTML 拼接不引入任何新样式。前端同事验收时只看到“多了一个推荐区块”完全不知道背后跑了大模型。3.2 错误隔离与优雅降级为什么try/catch里要写两行日志老系统前端有个致命习惯全局window.onerror会捕获所有 JS 错误但只上报错误消息不包含堆栈。所以ai-enhance.js的catch块里我写了两行日志console.warn([AI Enhance] Recommend failed for order, orderId, err); console.error([AI Enhance] Full error, err); // 这行触发 window.onerror上报完整错误对象第一行warn用于快速定位问题orderId和错误摘要第二行error触发全局错误监控上报err.stack。这样运维在 Kibana 里搜AI Enhance就能看到[WARN] AI Enhance Recommend failed for order 88923 TypeError: Cannot read property map of undefined [ERROR] AI Enhance Full error TypeError: Cannot read property map of undefined at loadRecommend (ai-enhance.js:45:22) at async Promise.all (index 0)比单纯报502有用一百倍。更重要的是降级策略当fetch超时AbortSignal.timeout(5000)显示“加载中…” 3 秒后自动切换为“暂无智能推荐”当 AI 返回空数组[]显示“根据您的订单未找到相似商品”当data.code非空显示data.msg当 JS 执行报错如data.items.map is not a function捕获异常并显示通用提示。所有这些都封装在loadRecommend()函数里JSP 页面开发者只需加一个>const aiQueue []; let activeCount 0; const MAX_CONCURRENT 2; // 同时最多 2 个 AI 请求 function enqueueAiRequest(fn) { return new Promise((resolve, reject) { aiQueue.push({ fn, resolve, reject }); processQueue(); }); } function processQueue() { if (activeCount MAX_CONCURRENT || aiQueue.length 0) return; const task aiQueue.shift(); activeCount; task.fn() .then(task.resolve) .catch(task.reject) .finally(() { activeCount--; processQueue(); // 处理下一个 }); }这样即使页面上有 10 个>class RecommendRequest(BaseModel): order_id: str Field(..., patternr^\d{6,12}$) limit: int Field(ge1, le10)但老系统传参是?orderId0000012345带前导零的字符串Pydantic 会把它转成整数12345再转回字符串时变成12345丢失前导零。而老系统订单号是字符串类型数据库字段是VARCHAR(12)0000012345和12345是两个不同订单。我的解法是放弃 Pydantic用原生request.json() 手写正则校验app.post(/rpc) async def rpc_handler(request: Request): try: payload await request.json() method payload.get(method) params payload.get(params, {}) if method order_recommend: order_id params.get(order_id, ) # 严格按字符串校验保留原始格式 if not re.match(r^\d{6,12}$, order_id): return JSONResponse( content{jsonrpc: 2.0, error: {code: -32602, message: Invalid order ID format}, id: payload.get(id)}, status_code400 ) limit int(params.get(limit, 3)) if not (1 limit 10): raise ValueError(limit out of range) # 调用模型返回结果 result await call_llm_model(order_id, limit) return JSONResponse(content{jsonrpc: 2.0, result: result, id: payload.get(id)}) except Exception as e: return JSONResponse( content{jsonrpc: 2.0, error: {code: -32603, message: str(e)}, id: payload.get(id)}, status_code500 )这样order_id始终是原始字符串模型输入、日志记录、错误提示都保持一致。上线后订单号解析错误率从 23% 降到 0%。4.2 输出格式的“考古式还原”为什么不用pydantic.BaseModel而用字典拼接老系统前端 JS 里res.items[0].sku访问商品编码res.items[0].price访问价格。如果 FastAPI 返回{ items: [ { sku: A1001, price: 89.0 } ] }老系统会报错Cannot read property sku of undefined因为price是数字89.0而老系统 JS 期望字符串89.00它用parseFloat()转换但某些页面用toFixed(2)格式化要求输入必须是数字。我的解法是所有数值字段强制转成字符串并补零def format_price(price: float) - str: return f{price:.2f} # 确保两位小数如 89.0 → 89.00 result [ { sku: item[sku], name: item[name], price: format_price(item[price]), imgUrl: item[img_url] } for item in raw_items ]同时AIShim在转发响应前再做一次校验// 确保 price 是字符串且符合两位小数格式 if (item.has(price)) { String priceStr item.getString(price); if (!priceStr.matches(\\d\\.\\d{2})) { item.put(price, 0.00); // 强制兜底 } }双重保险确保前端永远拿到89.00而不是89.0或89。4.3 错误体的“方言翻译”如何把HTTP 400映射成老系统能懂的code/msgFastAPI 默认的 400 错误体是{ detail: Invalid order ID format }但老系统前端 JS 里if (res.code ORDER_ID_INVALID)期待的是code字段。所以AIShim的错误处理逻辑是if (response.statusCode() 400) { String body response.body(); // 解析 FastAPI 的 detail 字段 JsonObject json JsonParser.parseString(body).getAsJsonObject(); String detail json.has(detail) ? json.get(detail).getAsString() : Unknown error; // 翻译成老系统方言 String code, msg; if (detail.contains(order ID)) { code ORDER_ID_INVALID; msg 订单ID格式错误; } else if (detail.contains(limit)) { code LIMIT_OUT_OF_RANGE; msg 推荐数量超出范围; } else { code AI_SERVICE_ERROR; msg 智能推荐服务异常; } return HttpResponse.newBuilder() .statusCode(400) .header(Content-Type, application/json;charsetUTF-8) .body(Json.createObjectBuilder() .add(code, code) .add(msg, msg) .build().toString()) .build(); }这个翻译表code↔msg存在AIShim的resources/error-mapping.json里运维可以随时热更新不用重启服务。上线后前端报错从“detail: Invalid order ID format”变成“订单ID格式错误”用户投诉下降 76%。注意AI 服务的错误信息对老系统来说不是技术日志而是用户提示语。你不能指望一个 2013 年写的 jQuery 插件去解析现代 API 的detail字段。你要做的是把 AI 的“技术语言”翻译成老系统的“业务语言”。5. 真实踩坑记录从502 Bad Gateway到HTTP 400的完整排查链路上线第一周监控报警AIShim每小时出现 3-5 次502 Bad Gateway错误日志显示unexpected status 502 bad gateway: unknown error, url: http://127.0.0.1:1572。这不是偶发故障而是有规律的批量失败。我按以下步骤逐层排查5.1 第一步确认502的真实来源——不是 AI 服务而是 Nginx我登录AIShim所在服务器用curl -v http://localhost:1572/rpc直连 AI 服务$ curl -v http://localhost:1572/rpc POST /rpc HTTP/1.1 Host: localhost:1572 Content-Type: application/json HTTP/1.1 200 OK Content-Type: application/json Content-Length: 128 {jsonrpc:2.0,result:[...],id:1}AI 服务本身健康。再用curl -v http://old-system.company.com/ai/order/recommend?orderId123调用AIShim$ curl -v http://old-system.company.com/ai/order/recommend?orderId123 GET /ai/order/recommend?orderId123 HTTP/1.1 Host: old-system.company.com HTTP/1.1 502 Bad Gateway Server: nginx/1.10.2 htmlbodyh1502 Bad Gateway/h1/body/html确认502出现在AIShim到 Nginx 这一段。查看 Nginx 日志2024/06/15 10:23:41 [error] 12345#0: *6789 upstream prematurely closed connection while reading response header from upstream, client: 10.0.1.100, server: old-system.company.com, request: GET /ai/order/recommend?orderId123 HTTP/1.1, upstream: http://127.0.0.1:1572/rpc, host: old-system.company.com关键词upstream prematurely closed connection—— Nginx 认为AIShim的响应不完整。5.2 第二步抓包分析AIShim响应头——发现Transfer-Encoding: chunked与Content-Length冲突我在AIShim服务器上用tcpdump抓包tcpdump -i lo port 1572 -w aisim.pcap用 Wireshark 打开过滤http看到AIShim返回的响应头HTTP/1.1 200 OK Content-Type: application/json Transfer-Encoding: chunked Connection: keep-alive但响应体是完整 JSON没有分块传输。Nginx 1.10 不支持Transfer-Encoding: chunked透传它等待第一个 chunk超时后关闭连接返回502。5.3 第三步验证AIShim的HttpClient行为——确认它默认启用chunked我写了个最小测试类HttpClient client HttpClient.newBuilder().build(); HttpRequest req HttpRequest.newBuilder() .uri(URI.create(http://localhost:1572/rpc)) .header(Content-Type, application/json) .POST(HttpRequest.BodyPublishers.ofString({\jsonrpc\:\2.0\,\method\:\test\})) .build(); HttpResponseString res client.send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.headers()); // 打印响应头输出{content-type[application/json], transfer-encoding[chunked], connection[keep-alive]}证实java.net.http.HttpClient默认用chunked编码。5.4 第四步强制禁用chunked——用Content-Length替代修改AIShim的响应构造逻辑// 读取完整 body String body response.body(); // 构造新响应显式设置 Content-Length禁用 chunked return HttpResponse.newBuilder() .statusCode(response.statusCode()) .header(Content-Type, application/json;charsetUTF-8) .header(Content-Length, String.valueOf(body.length())) // 关键 .body(body) .build();再抓包响应头变成HTTP/1.1 200 OK Content-Type: application/json Content-Length: 128 Connection: keep-aliveNginx 日志不再出现upstream prematurely closed connection502报警归零。5.5 第五步深挖根源——为什么chunked会触发 Nginx 旧版本 Bug查阅 Nginx 1.10 官方文档发现其proxy_buffering默认开启但对chunked响应的 buffer 处理有缺陷当上游响应体小于proxy_buffer_size默认 4k时Nginx 会尝试一次性读取但chunked编码要求按块解析导致解析失败。而AIShim的响应体普遍在 200-500 字节正好踩中这个坑。最终解决方案短期AIShim强制Content-Length长期推动运维升级 Nginx 到 1.18支持chunked透传但需业务部门审批排期 6 个月。这个排查过程耗时 17 小时但它教会我一件事在老系统环境里502不是网络问题是协议兼容性问题解决它不靠重启服务而靠读懂 HTTP 协议栈每一层的行为。6. 效果验证与业务价值不是 PPT 上的“AI 赋能”而是订单转化率提升 11.3%技术落地后我们没开庆功会而是埋点看数据。在订单详情页>