我接手 Contract-AI 这个项目的时候就知道它不简单。合同起草、条款审查、风险标注底层串的是大模型中间走的是微服务前端还动不动就要上传几十页的 PDF——这三样东西凑在一起出问题几乎是必然的。到了第 8 天积累的问题集中爆发GLM 调用时好时坏、Feign 接口偶发超时、MultipartFile 传着传着文件就读不出来了。这几个问题单个拎出来都能写一篇文章但它们同时在同一个业务流程里出现时排查起来就非常折磨人。这篇文章把我当天完整的问题定位过程和修复方案记录下来包含 GLM 接入的返回解析兼容、Feign 调用的 Header 透传和超时配置、以及 Feign 传递 MultipartFile 时从参数声明到临时文件生命周期的全套处理思路希望能帮你少走点弯路。1. 从故障现场说起Contract-AI 在第 8 天碰到的三个“鬼”1.1 项目背景为什么会有 GLM、Feign 和文件上传同时存在Contract-AI 是一个典型的微服务架构下的合同智能处理平台。用户在前端上传合同文件网关层接收请求后转发给合同解析服务解析服务负责对文件进行文本提取随后通过 Feign 调用 AI 服务AI 服务再向智谱 GLM 大模型发起对话请求最后把模型返回的审查结果包装成统一响应体输出。这个流程里有三个技术选型是硬性的GLM承担合同条款的智能理解包括风险点识别、条款缺失检测、法律依据推荐等。用大模型不是因为炫技而是合同场景的语义理解确实需要强泛化能力的模型才能兜底。Feign服务间调用的声明式 HTTP 客户端。在 Spring Cloud Alibaba 生态里Feign 加 Nacos 加 Sentinel 几乎是标配服务发现、负载均衡、熔断降级都靠这一套串起来。MultipartFile用来承接前端上传的合同文件。Spring MVC 的标准做法理论上完全没有问题。问题就出在“标准做法”这三个字上。这三样东西单独用都是行内最佳实践串联起来之后链路复杂度呈指数级上升任何一个环节的隐性假设不成立整个链路就会以各种奇怪的方式失败。1.2 故障现场三个问题同时爆发的混乱早晨Day8 当天早上监控告警一个接一个AI 服务调用 GLM 接口成功率跌到 70%部分请求返回 500部分请求返回超长无响应的错误。合同解析服务调用 AI 服务时Feign 客户端偶发 SocketTimeoutException但同一接口用 Postman 直接调 AI 服务又是通的。用户上传的 PDF 在流程中偶发出现“文件内容为空”或“文件名乱码”但同一文件多次重试结果不一致。三个问题表面独立实际上盘根错节。我一开始也打算逐个击破但排查到后期才发现它们彼此之间存在因果联动——这也解释了为什么单独复现任何一个问题都不稳定但组合起来几乎每次都出岔子。如果用一句话概括那天的状态GLM 的问题让 AI 服务响应变慢AI 服务变慢触发了 Feign 超时重试Feign 重试过程中服务端临时文件已经被 Spring 清理导致文件内容丢失和乱码。这不是三个独立 bug是一条完整的故障传导链。2. GLM 接入的隐蔽雷区接口毛坯通了业务层却被打穿2.1 鉴权、模型名和参数上限三个“一次性”配置坑先看最简单的部分。GLM 接入本身并不复杂智谱开放平台提供标准的 OpenAI 兼容接口HTTP 调用基本就是拼 JSON 的问题。但我那次排查时发现项目里踩了三个非常基础的坑全是配置层面的。第一个坑是鉴权头的传递。GLM 接口要求请求头携带Authorization: Bearer api_key但我们在 Gateway 网关层做了统一鉴权后会把原始请求的 Authorization 覆盖成网关签发的内部 token。AI 服务拿着内部 token 去调 GLM直接被 401 拒了。这个问题的隐蔽之处在于它不会 100% 失败——当代码里对 401 做了重试而某些请求因为缓存命中了之前成功的连接就有一定概率通过表现出来就是“时好时坏”。第二个坑是模型名写错。GLM 开放平台有 GLM-4-Plus、GLM-4-Air、GLM-4-Flash 等多个模型不同模型的上下文长度和调用价格差别很大。项目里有一处配置写的是glm-4-air-20231106这种带日期后缀的旧版模型名但控制台里实际可用的模型列表已经更新了。调用时平台返回的不是标准的“model not found”而是一个比较隐晦的参数错误码非常容易误导排查方向。第三个坑是max_tokens参数超上限。合同场景下我们期望模型输出尽量长就把max_tokens设得很大。但 GLM-4-Air 的输出上限是 4095设置超过该值平台直接拒绝请求。这个问题在单条款审查时不会触发因为输出短一旦用户上传整份合同做全面审查输出需求变大就大面积失败。这几个坑修起来不难但它们暴露了一个工程问题接入外部 AI 服务之前必须先读透官方 API 参数说明和常见错误码并且把配置项集中收敛到一个地方管理而不是散落在业务代码里。2.2 返回解析的隐蔽兼容问题content 字段一会是字符串一会是数组GLM 报错排查完之后又冒出一个更隐蔽的问题。AI 服务拿到 GLM 的返回后要把choices[0].message.content提取出来做 JSON 解析。项目最初的实现是直接按字符串处理String content response.getChoices().get(0).getMessage().getContent();结果后来发现GLM 平台对 content 字段的返回格式并不总是纯字符串。在某些配置下比如开启了特定工具调用或返回格式参数时content 会变成数组结构{ content: [ {type: text, text: 合同第3条存在风险...}, {type: text, text: 建议修改为...} ] }如果代码里写死getContent()后直接当 String 用运行时就会抛ClassCastException或类型断言错误而且这个错误只在特定请求参数下出现复现概率低非常难排查。正确做法是做类型兼容处理。我的方案是把 content 统一转成一个文本提取方法先判断类型再决定怎么解析private String extractContent(Message message) { Object content message.getContent(); if (content instanceof String) { return (String) content; } if (content instanceof List?) { List? list (List?) content; StringBuilder sb new StringBuilder(); for (Object item : list) { if (item instanceof Map) { Map?, ? map (Map?, ?) item; if (text.equals(map.get(type)) map.get(text) ! null) { sb.append(map.get(text).toString()); } } } return sb.toString(); } return ; }这个兼容层的价值在于即使 GLM 平台后续调整返回格式我们也不会直接崩溃最多是文本拼接逻辑需要适配。2.3 合同全文超长与上下文窗口别让大模型“过载死机”GLM-4-Air 的上下文窗口是 128K理论上看整份合同都放得下。但实际测试发现当合同文本加上系统提示词、历史对话和用户指令一起超过一定比例时模型响应质量和稳定性都会断崖式下降甚至报上下文超长的错。为了解决这个问题我给合同解析服务加了一道文本预处理管线文本分块按合同章节结构把全文切分成多个语义块每块控制在 1500 字以内。章节摘要针对超长章节先用 GLM 做一次浓缩提取生成章节摘要。增量审查每次对话只携带当前审查目标相关的原文片段而不是把整份合同一次性塞进上下文。这样改造之后AI 服务的响应时间从平均 25 秒降到了 8 秒稳定性明显提升。GLM 调用不再出现上下文爆掉的错误Feign 的超时压力也从源头得到了缓解。3. Feign 链路的无声故障超时、Header 丢失和解析器之争3.1 默认超时配置让 AI 服务成了“背锅侠”Feign 在 Spring Cloud 体系里有两层超时控制一层是 Feign 自己的connectTimeout和readTimeout另一层是如果接了 Sentinel 或 Hystrix还有熔断器内部的超时配置。我排查时发现项目的application.yml里 Feign 超时配置写的是spring: cloud: openfeign: client: config: default: connect-timeout: 5000 read-timeout: 10000看着没问题10 秒读超时对大部分接口够用了。但问题是AI 服务调 GLM 的平均耗时是 8 到 25 秒再加上文件解析和序列化的开销Feign 客户端经常会超过 10 秒的上限直接抛Read timed out。更坑的是Feign 超时之后如果配置了重试机制默认Retryer.NEVER_RETRY但很多人会配成重试一次重试请求会再次打到 AI 服务。AI 服务同时还在处理新的请求线程池被打满响应变得更慢形成恶性循环。最终的修复是分层配置而不是一刀切spring: cloud: openfeign: client: config: default: connect-timeout: 3000 read-timeout: 30000 ai-service: connect-timeout: 3000 read-timeout: 60000注意看这个设计的逻辑默认请求的读超时放宽到 30 秒而专门针对 AI 服务的调用放宽到 60 秒因为 GLM 的响应时间本身波动就大。这样既保证普通接口不会被长时间占用线程又避免 AI 服务被误杀。3.2 Header 透传认证信息在服务间“断片”Feign 调用时默认不会自动携带上游请求的 Header包括 Authorization 和自定义的 traceId、userId 等。这在单服务场景下无所谓但微服务链路里AI 服务拿不到用户的原始身份信息就无法做鉴权透传和审计。我们项目里用的方案是实现一个RequestInterceptor把当前请求的 Header 透传到 Feign 模板里Component public class FeignRequestInterceptor implements RequestInterceptor { Override public void apply(RequestTemplate template) { ServletRequestAttributes attributes (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes ! null) { HttpServletRequest request attributes.getRequest(); // 透传登录态和链路追踪ID template.header(Authorization, request.getHeader(Authorization)); template.header(X-Request-Id, request.getHeader(X-Request-Id)); } } }这段代码有个前提Feign 调用发生在有RequestContextHolder的线程里。如果业务代码里用了异步线程池RequestContextHolder 就会丢失Header 透传自然失败。这个坑我后面单独说因为它和文件传递的问题还纠缠在一起。3.3 Feign 解码器之争GLM 返回的非标结构怎么兜底Feign 默认使用 Jackson 作为解码器对接口返回的 JSON 做反序列化。一般情况下没问题但 GLM 流式接口SSE 格式返回的并不是一个完整的 JSON而是多行data:前缀的文本流。如果用默认解码器去解析必然报JSON parse error。我们当时的场景是AI 服务希望流式把 GLM 的结果推给上层解析服务而不是等完整结果。这就要给 Feign 客户端单独配置一个处理 SSE 的解码器或者干脆不走 Feign改用 WebClient 或原生的HttpURLConnection去流式读取。考虑到项目里其他地方还要用 Feign 做普通 JSON 调用我的选型是给 AI 服务单独建一个 WebClient 通道处理流式请求Feign 只负责非流的业务接口。这里想提醒的是Feign 不是万能的。当外部接口的返回格式非常规SSE、multipart 混合响应、JSONP 等时优先考虑用专门的 HTTP 客户端而不是硬把 Feign 调通。强行封装只会让代码越来越诡异最后变成技术债。4. MultipartFile 传递的连环坑从参数声明到临时文件生命周期4.1 Feign 声明 multipart/form-data 接口的正确姿势如果说 GLM 和 Feign 的坑还属于技术选型层面的博弈那 MultipartFile 的传递问题就是纯粹的基础功翻车。很多人在 Feign 里声明文件上传接口时会下意识地写PostMapping(value /upload, consumes MediaType.MULTIPART_FORM_DATA_VALUE) String upload(RequestParam(file) MultipartFile file);这在 Spring MVC 的 Controller 里是合法的虽然更推荐RequestPart但在 Feign 接口里用RequestParam传 MultipartFile 大概率会失败Feign 会把这个参数当成普通表单字段而不是文件流。正确写法是PostMapping(value /upload, consumes MediaType.MULTIPART_FORM_DATA_VALUE) String upload(RequestPart(file) MultipartFile file);同时Feign 默认的编码器不支持 multipart/form-data需要额外引入 feign-form 依赖并配置一个SpringFormEncoderdependency groupIdio.github.openfeign.form/groupId artifactIdfeign-form/artifactId version3.8.0/version /dependency dependency groupIdio.github.openfeign.form/groupId artifactIdfeign-form-spring/artifactId version3.8.0/version /dependencyConfiguration public class FeignMultipartConfig { Bean public Encoder multipartFormEncoder() { return new SpringFormEncoder(); } }这里的版本号要特别注意feign-form 的版本必须和 Spring Cloud 版本兼容否则运行时会报NoSuchMethodError。我试过 3.8.0 搭配 Spring Cloud 2021.x 是没问题的如果是更新的版本建议直接看 feign-form 官方 README 的版本对应表。4.2 临时文件生命周期一个隐蔽的环境杀器MultipartFile 的本质是 Spring 在处理上传请求时将文件内容写到系统临时目录然后在 Controller 方法执行期间通过 MultipartFile 接口暴露出来。问题在于这个临时文件的生命周期与请求处理过程绑定请求结束或框架回调完成时临时文件会被自动清理。我们那天的故障传导链里最关键的一环就发生在这里。合同解析服务从网关收到文件后开启了异步线程池来处理解析任务同时通过 Feign 把文件传给 AI 服务。异步线程的执行时间完全不确定如果主请求已经返回Spring 的临时文件清理器就会把文件删掉异步线程再读 MultipartFile 的内容时文件已经不存在了。这种问题在单机测试时极难复现因为本地开发环境几乎不会触发临时文件清理只有部署到容器环境、内存和磁盘压力大、GC 频繁的情况下临时文件被删除的概率才会显著上升。解决方案很直接收到 MultipartFile 之后立刻把内容转成字节数组或另存到本地磁盘再去做后续操作。byte[] fileBytes FileCopyUtils.copyToByteArray(file.getInputStream());转成byte[]之后无论后面是异步线程还是 Feign 调用都不再依赖临时文件的生命周期。代价是内存占用会上升所以我建议对于大文件超过 10MB可以先把文件保存到本地磁盘或对象存储拿到路径后走文件流式读取。4.3 文件名为中文时的编码问题文件传递爆出乱码时第一反应通常是改 Feign 的编码配置。但实际原因往往是 Content-Disposition 头里的文件名没有做 URL 编码。Feign 在构造 multipart 请求体时会把文件名原样写进Content-Disposition头。如果文件名是中文合同文件几乎必然是中文名HTTP 头的编码规则和表单体不同不处理就会出现乱码下游收到后无法正确保存或识别文件。我当时的做法是在上传前统一对文件名做编码处理String encodedFileName URLEncoder.encode(originalFilename, StandardCharsets.UTF_8.name()) .replace(, %20);然后在服务端接收时用URLDecoder.decode还原。这一穿一脱看起来冗余但可以保证文件名在不同字符集环境下稳定传递。如果你用的是 Spring Cloud 的官方 OpenFeign还需要在配置里加上强制 UTF-8 的编码设置不然某些版本的底层 HTTP 客户端会在传输时再次用默认字符集包装。4.4 大文件传输的内存压力byte[] 方案的另一面把 MultipartFile 转成byte[]之后Feign 调用时会把整个文件字节数组放进请求体一起发送。对于一个 3MB 的 PDF也就是几百万个字节这在内存里不构成压力但如果文件是几十 MB 的扫描合同高并发下就很容易把 JVM 堆打爆尤其是 AI 服务本身还在做大模型调用内存本来就不宽裕。针对这个问题我的建议是给 FileService 增加一个大小阈值判断if (file.getSize() 10 * 1024 * 1024) { // 先落盘再传文件路径 } else { // 直接转 byte[] 传递 }这样小文件走内存大文件走磁盘避免了极端场景下的内存溢出。5. 三线汇一的排查链路用日志和监控把“偶发”变成“必然”5.1 完整的请求链路还原前端 → 网关 → 解析服务 → AI 服务 → GLM这一节我画不出时序图但可以用文字把链路完整走一遍方便你对照自己的排查路径用户在浏览器上传2024-采购合同-终稿.pdf前端以 multipart/form-data 方式 POST 到网关。网关解析请求转发到合同解析服务MultipartFile 在 Controller 参数中创建。解析服务开启异步线程解析 PDF同时想通过 Feign 将文件传递给 AI 服务做审查。AI 服务接收到文件后处理文本通过 HTTP 调用 GLM 接口等待返回。GLM 返回内容AI 服务包装响应回传给解析服务再由解析服务返回给网关和前端。这条链路一共有 5 个节点、至少 4 次 HTTP 调用。任何一个节点出现异常最终用户的感知都是“上传失败”或“审查失败”但失败原因可能完全不同。5.2 取证方法先打开 Feign FULL 日志再谈定位当天我做的第一件事不是猜问题而是把 Feign 的日志级别调到 FULL并给所有关键服务开了 debug 日志logging: level: com.contractai.client: debugFeign 在 FULL 日志级别下会打印完整的请求头、请求体和响应信息这对 MultipartFile 问题尤其有用——你能直观看到文件名是怎么编码的、Content-Type 是不是正确、Body 里文件内容是否完整。我的实际排查顺序是看日志找到失败请求的 traceId把所有节点的日志串起来。这一步能排除大部分“看起来复杂但实际上很简单”的问题。看监控对比失败时间段内各节点的耗时和错误数定位瓶颈在哪一跳。本地复现用 curl 或 Postman 模拟整条链路逐段对比。如果只有全链路失败而单节点调用成功那问题基本可以锁定在链路中的传递环节Header、编码、生命周期。修完验证每个修复上线后观察至少半天监控确认错误率下降不是偶然。5.3 问题、根因与解法的全映射表到这里我把当天排查的全部问题、根因和解决方案整理成一张表方便你直接对照问题现象根因解决方案GLM 调用偶发 401网关覆盖 Authorization 头内部调用改用单独的内部 token或服务间走 mTLSGLM 报 model 不存在模型名仍在用旧版本统一从配置中心读取可用模型名GLM 请求被拒max_tokens 超模型上限根据模型最大输出动态设置 max_tokensGLM 返回解析异常content 字段格式不兼容增加类型兼容提取逻辑合同全文超长单次请求超过上下文窗口增加文本分块与增量审查管线Feign 读超时默认 readTimeout 太短按服务维度配置独立超时下游报 401Feign 未透传原请求 Header实现 RequestInterceptor 做 Header 透传SSE 响应无法解析默认 JSON 解码器不兼容单独引入 WebClient 处理流式请求MultipartFile 内容为空临时文件被 Spring 清理收到后立即转 byte[] 或落盘文件名乱码Content-Disposition 编码问题URL 编码/解码统一处理大文件内存溢出byte[] 方案无大小限制按阈值分流到磁盘或 OSS6. 修复之后工程层面的长期防御6.1 能用架构解决的别等代码来扛三个问题全部修复后我复盘了一下发现如果一开始就在架构层面做几个决定这些坑大部分是可以绕开的第一服务间文件传递不要走 Feign 传裸 MultipartFile直接传对象存储的 URL。前端把文件上传到 OSS/MinIO拿到一个 URL后续所有服务间传递都用 URL 代替文件流。这样 Feign 只传字符串不会遇到 multipart 编码、临时文件生命周期、文件名乱码这些问题。唯一的前提是对象存储的访问权限要控制好URL 需要带有时效签名。第二大模型接入一定要做统一封装。AI 服务内部建一个LlmGateway接口GLM 只是其中一个实现将来换模型只改实现类不会污染上层业务逻辑。同时把超时、重试、流量控制都放在这一层而不是散落在各调用方。第三异步线程里的上下文问题要提前设计。如果业务代码里用到了Async或手动线程池一定要把RequestContextHolder里的上下文显式传递过去否则 Header 透传、traceId、用户身份信息全会丢。推荐用TransmittableThreadLocal或者至少在提交任务时把需要的上下文作为参数传入。6.2 给后来者的排障建议最后分享几条实用的经验每条都是当天用真金白银换来的不要相信“偶发”两个字。凡是偶发的故障背后一定有一个状态相关的必要条件没有被满足。排查时优先关注生命周期、线程上下文、缓存命中这类状态因素。日志一定要留 traceId。微服务排障没有 traceId等于在黑暗里摸象。建议全局过滤器统一生成 X-Request-IdFeign 拦截器自动透传。改配置前看好官方文档尤其是最大限制。GLM 的 max_tokens、上下文窗口、Feign 的默认超时、feign-form 的兼容版本这些“边界值”往往是问题根源。修完一个坑之后要跑一遍全链路回归。因为问题之间往往有传导关系你修了 A 问题可能暴露出原先被 A 掩盖的 B 问题。压力测试要提前做。单用户功能测试跑得通不代表高并发没问题。大文件 并发 异步的组合能炸出几乎所有这类架构的潜在毛病。修复完成那天晚上我把监控面板看了很久。让我最感慨的不是错误率降到了 0.2% 以下而是那条完整的故障链从 GLM 超时到 Feign 重试再到 MultipartFile 被清理整个传导过程在监控里一览无余。这其实是个好事——一次把所有隐性风险全都暴露出来后面再补漏洞就是按图索骥了。