单测全绿上生产就翻车说的就是LLM数据管线。做了一段时间LLM应用后端我最深的体会是代码逻辑层面的bug真不难查真正的坑全在“数据流动”上。尤其是在RAG、批量抽取、异步处理这类管线型工程里单元测试跑得再漂亮也只是证明了“单条数据在理想环境下能走通”根本证明不了“一批真实数据在跑批、重试、并发时不会互相污染”。这篇博文就记录两个我实际踩过的运行时问题。它们有一个共同特征用单元测试都没法覆盖因为它们不在“函数逻辑”里而在“任务与任务之间、批次与批次之间”的隐式契约里。一个叫上下文污染一个叫并发共享状态泄漏。如果你也在搭LLM数据管线这两条经验应该能帮你少走不少弯路。1. LLM数据管线的测试盲区为什么存在1.1 单元测试的三大假设LLM管线一条都不满足单元测试的基本逻辑是给定输入断言输出。它隐含了三个假设——隔离性每个用例独立运行、确定性相同输入必得相同输出、无副作用测试不依赖外部系统状态。可LLM数据管线是什么样的它的核心处理对象不是纯函数而是“带状态的文本流”。一条文本进来可能要经过切分、向量化、缓存查询、模型调用、结果解析、入库然后它的输出又会成为下一条文本检索时的上下文。这里每一步都不是孤立的模型调用有吞吐限制和随机性缓存有命中和淘汰入库有重试和幂等性问题。更不用说生产环境几乎都是并发跑批的一批几十上百个任务同时推进任务之间共享着大语言模型客户端、内存缓存、数据库连接池这些公共资源。单元测试在这套体系里能覆盖什么它只能覆盖“单次调用的函数逻辑”——比如prompt模板拼得对不对、JSON解析器能不能处理某个边界返回。至于“A任务的结果会不会污染B任务的检索上下文”“重试之后会不会写入重复数据”“并发场景下两个任务会不会拿到彼此的缓存结果”这些全都是跨函数、跨任务、跨实例的运行时行为单测根本无从下手。1.2 LLM管线特有的三类“静默型缺陷”我给这类问题起了个名叫“静默型缺陷”——它不报错不红屏但结果就是错的。单元测试之所以覆盖不了是因为它设计出来就是查“崩溃型缺陷”的查不了这种数据悄悄变质的问题。状态泄漏上游任务把信息写进了一个共享位置下游任务读到了不该读的东西。时序竞态并发任务并行执行时两个任务交错访问了同一个可变对象最终结果取决于执行顺序。非确定性放大模型输出本身就有随机性管线如果再用有状态的聚合逻辑随机性就会被放大成系统性错误。这三种缺陷有一个共同特点只有在“多个任务同时在线”时才会显现。你写单测时通常只跑一个任务、只喂一条数据等于永远在“单车道空驶”状态下验证系统当然测不出“多车并线时互相别车”的问题。1.3 单测全绿带来的虚幻自信我不反对单元测试它是质量下限的兜底。问题在于很多人把“单测通过”当成“系统没问题”的充分条件这在LLM工程里尤其危险。LLM管线里的真实复杂性——token截断怎么处理、缓存命中的内容是哪一批的、重试补偿是否幂等——这些恰恰是单元测试的结构性盲区。换句话说单测验证的是“计划中的路径”而生产事故几乎都发生在“计划外的交错”——重试的交错、批次颗粒度的交错、共享缓存的交错。这些盲区本质上不是代码缺陷而是数据契约缺陷单测这个工具在设计上就不擅长发现它们。下面进入正题把两个典型案例拆开讲。2. 缺陷实录一上下文污染——父请求把“记忆”串给了子请求2.1 事故现场文档摘要里混入了无关内容第一个项目是做一个RAG知识库的批量预处理管线。原始文档进来后系统会先调用LLM生成一段摘要再把摘要和正文一起向量化入库。这段逻辑看起来非常简单核心函数大概是def generate_summary(doc_text: str, model_client) - str: prompt SUMMARY_TEMPLATE.format(doc_textdoc_text) response model_client.chat(messages[{role: user, content: prompt}]) return response[content]当时每个单元测试都通过了喂一段样本文档断言返回的是一个字符串长度大于某阈值。一切都很正常。结果一上生产跑完一批100篇文档后人工抽检发现了一个诡异现象第37篇文档的摘要里混入了一段第12篇文档中的原句。更蹊跷的是第12篇文档本身并没有出现在第37篇的正文里向量检索也不可能跨文档召回。这显然不是信息检索问题而是摘要生成过程的输入被污染了。2.2 根因定位单次调用没问题批量调用就出事排查过程从最简单的地方开始单独重跑第37篇文档摘要完全正常单独重跑第12篇文档也正常。但只要按照生产顺序连续跑第37篇就会抽风。这个“单独正常、连着跑就出错”的现象是典型的跨任务状态残留。顺着这个思路继续查发现模型调用的封装层为了省token加了一个“对话记忆”功能。它维护着一个全局的消息数组每次调用前把历史消息塞进去当上下文# 简化后的污染代码 _conversation_history [] def call_llm_with_memory(user_content: str): _conversation_history.append({role: user, content: user_content}) response model_client.chat(messages_conversation_history) _conversation_history.append({role: assistant, content: response[content]}) return response[content]单测为什么会漏因为在测试环境里每个test case跑完就完事了_conversation_history虽然在累积但断言只看最后返回的字符串不关心历史里有什么。而生产环境是连续跑几十上百篇文档前一篇的user内容、assistant输出全都被当作上下文传给了下一次调用。第37篇被污染本质上是因为第12篇处理完后它的原文还躺在全局列表里被第37篇的调用当成了“历史对话”一起发给了模型。2.3 修复方案让上下文生命周期显式化修复说穿了不值钱把全局变量改成局部变量上下文要么彻底不用要么严格按“一次任务一个实例”来创建。def generate_summary(doc_text: str, model_client) - str: # 不共享任何状态每次调用都是独立的 return call_llm_single_turn(SUMMARY_TEMPLATE.format(doc_textdoc_text), model_client)如果某个场景确实需要多轮对话记忆那也绝不能用一个模块级list而是要包在一个ConversationSession对象里由上层任务决定何时创建、何时销毁。比如session ConversationSession(model_client) for chunk in chunks: session.add_user_message(chunk) reply session.complete() # 处理一个完整任务后session自动释放不会串到下一个任务这里想强调一个经验凡是出现“模块级可变对象”就要立刻警觉它是否跨任务共享。单元测试天然串行执行往往掩盖了这种共享状态的问题。要么改成无状态的写法要么让状态的边界跟着业务任务走而不是跟着模块走。全局可变状态是LLM管线的头号污染源没有之一。3. 缺陷实录二并发共享状态泄漏——缓存把不同会话的数据串了3.1 事故现场结构化抽取结果张冠李戴第二个项目是一条异步流水线负责从用户上传的对话记录里抽取结构化信息比如客户意向、商品偏好、沟通评分。每条记录进来后会先做一次缓存查询——如果这条记录的处理结果在缓存里就直接返回缓存结果省一次LLM调用。这个设计本身没问题问题出在缓存的key上。代码大概长这样lru_cache(maxsize512) def extract_structured_info(session_id: int, text: str) - dict: response llm_client.chat(messages[...]) return parse_json(response[content])我把session_id和text作为缓存key了表面上看起来很合理不同会话、不同文本缓存不会串。可生产环境是异步并发跑批服务实例不止一个。有一次线上监控发现A用户的上传记录里抽取出的“偏好标签”竟然是B用户的内容。查了下日志两个用户的文本内容非常相似——很多人上传的客服对话都是类似“咨询退货流程”这种话术。问题出得更隐蔽lru_cache是进程内的但LLM服务实例在并发时会走共享的Redis缓存层。项目组在接入Redis缓存时key只取了文本的哈希值完全没拼session_id# 伪代码示意 cache_key fllm:extract:{hash(text)}于是只要两个不同会话的文本内容一样它们的LLM抽取结果就被认为是同一个东西后到的请求直接命中了先到请求的缓存。而单测环境里所有用例串行执行缓存命中与否完全可控你根本不会看到“两个并发任务同时写同一个key”的场景。3.2 排查过程从脏数据反推状态污染这个问题的排查比上下文污染麻烦得多因为它是随机性的不是每次必现只是间歇性出现而且只出现在特定文本重复时。我的排查思路是这样的第一步对比出错记录的时区。把A用户和B用户的抽取结果并排摆在一起发现A用户记录里写入的标签在时间上正好对应于B用户记录的处理时刻。这说明不是“入库后被动串改”而是“处理阶段就拿到了对方的结果”。第二步看缓存层。把Redis里的数据导出来发现不同session_id对应的key完全一致也就是说这个key根本没有区分业务实体。到了这一步根因已经清楚了缓存key设计缺少业务上下文维度。第三步模拟并发测试。我用线程池压了100条相同文本、不同session_id的请求结果轻松复现。单测阶段之所以没发现是因为——我根本没写过并发场景的测试用例。所有缓存相关单测都是串行的先写缓存、再读缓存、断言一致完全没有覆盖“多个任务同时读同一key”的竞态。3.3 修复方案缓存key带上业务上下文配合幂等重试修复方案有两层。第一层治标缓存key强制包含全部业务标识绝不只对文本做哈希。# 修复后的缓存key设计 cache_key fllm:extract:{session_id}:{hash(text)}其实session_id在这里也不能完全避免串数据。更稳妥的做法是给每条上游记录生成一个全局唯一的request_id整个管线的所有中间产物都挂在这个request_id下面cache_key fllm:extract:{request_id}第二层治本LLM管线的缓存和重试必须幂等。我们的流水线用了消息队列消费端处理失败后会重试。原来的重试逻辑只判断“这条记录有没有处理过”没判断“处理结果是否完整写入”。结果就是第一次调用超时了但LLM其实已经产生了结果重试时缓存不命中LLM再次调用返回了另一个结果两条结果都写入了数据库。这个问题和缓存污染叠加在一起数据想不脏都难。修复后的逻辑是每次处理一个消息前先检查这个request_id是否已有“完成态”的结果记录如果有直接跳过不管结果是何时产生的。这样的“状态机重试”才能保证幂等普通的重试只会放大并发污染。4. 从缺陷到防御LLM数据管线运行时问题排查手册4.1 排查思路四步走经历了这两个事故之后我给自己定了一套排查规范。遇到“单测通过但数据不对”的问题按下面四个方向查通常半小时内能定位。查共享状态先扫一遍代码里有没有模块级可变对象、全局缓存、类级别的可变属性。LLM管线的污染绝大多数发生在这里。查缓存key维度把缓存key打印出来看它是否包含了完整业务上下文。如果发现只对文本内容做哈希那基本已经锁定了问题。查并发交错场景写一个小的并发烟雾测试——几十个线程同时跑同一个任务故意制造竞态。这个测试虽然不常跑但每次改管线代码都应该跑一遍。查重试幂等性模拟“第一次调用超时但实际已成功”的场景看系统会不会重复写入或产生双份结果。4.2 常见问题速查表症状可能根因排查手段修复方向文档A的结果包含文档B的内容共享上下文对象跨任务残留单跑与连跑对比打印历史上下文上下文按任务生命周期管理并发场景下不同任务拿到相同结果缓存key维度缺失打印缓存key比对业务字段key带上request_id等全维度标识重试后出现重复数据写入逻辑非幂等模拟超时后重试增加“完成态”状态判断实现幂等数据时对时错无规律非确定性放大共享状态并发烟雾测试消除可变共享状态让管线无状态化4.3 预防性设计三条底线踩过这两个坑之后我给自己定了几条铁律写进团队规范里了每条都是拿事故换来的。第一LLM数据管线必须无状态化。任何函数只要输入是确定的文本输出就不该依赖函数外部任何可变的东西。需要上下文就显式传参需要缓存就显式传对象绝不使用全局变量和模块级缓存。第二缓存key必须包含完整业务实体标识。文本哈希只能作为辅助维度不能作为唯一维度。只要业务上存在“两条记录文本相同但实体不同”的可能key就必须把实体ID放进去。第三集成测试必须模拟多任务并发。单测负责逻辑正确性集成测试负责管线契约正确性。我后来在管线里加了一个“并发冒烟测试”每次部署前自动跑同时处理10条语义相近的文本断言每个输出只包含自己输入里的内容。这个测试的成本很低但价值极高——它能把前两个事故都扑灭在发布之前。写在最后说实话这两个问题修复之后我一度产生了“单测没用”的错觉。后来想通了单测不是没用而是它的作用边界非常明确——它验证代码符合预期但验证不了数据在流转过程中会不会被环境改变。LLM管线的特殊之处在于它的每一步处理都引入了不确定性而工程上的所有约定——缓存、重试、批量——又都在放大这种不确定性的副作用。所以单测仍然是第一道门但绝不能是最后一道门。真正的防线是在管线层面加一层“数据血缘断言”让每个中间产物都能追溯到它唯一的输入来源。这才是LLM数据管线工程里最值得投入的部分。
阅读完成 · 觉得有帮助?