1. 为什么我要给自家 LLM 应用“下毒”第一次听到“给 AI 系统下毒”这个说法很多人下意识会觉得是黑客干的事。其实在工程圈里这有个更正式的名字——Chaos Engineering混沌工程。它的核心思路特别朴素与其祈祷线上不出事不如主动制造故障看看系统到底能扛住几成。放到 LLM 应用上这件事变得比传统后端服务更棘手也更有必要。传统 Web 服务的故障面相对清晰网络抖动、磁盘打满、依赖超时、实例挂掉。你把这些注入进去观察熔断、降级、重试是否按预期工作就行。但 LLM 应用不一样它的“故障”很多时候不是进程崩了而是输出质量悄悄劣化——模型开始胡言乱语、检索召回了一堆无关文档、工具调用参数格式错乱、多轮对话里上下文被污染。这些故障不会触发任何告警监控面板一片绿但用户已经在骂街了。我负责过一个内部知识库问答系统上线头两周指标漂亮得不像话直到有同事反馈“它开始编造不存在的制度条款”。排查了半天才发现是检索层某天索引更新时混进了一批过期文档模型忠实地基于错误上下文给出了看似合理的答案。整个过程没有任何异常日志。那次之后我就下定决心必须把混沌工程这套方法论搬到 LLM 应用上来。这篇文章面向的是已经在做或准备做 LLM 应用落地的工程师尤其是负责稳定性、测试、SRE 方向的同学。我会把“故障注入”拆成可操作的几个层次从最外层的网络依赖一路深入到提示词、检索、记忆、工具调用这些 LLM 特有的环节给出 Python 层面的实现思路和实测经验。读完你应该能搭起一套属于自己的“下毒”流水线而不是停留在“知道有这么回事”。提示混沌工程的前提是你要先有可观测性。如果连一次请求经过了哪些环节、每环节耗时和输出都拿不到注入故障只会让你更懵。先把链路追踪和结构化日志补齐再谈注入。2. 先搞清楚 LLM 应用的故障面到底在哪2.1 从一次典型请求拆解出可注入的环节要下毒先得知道毒往哪下。一个标准的 LLM 应用请求大致会经过这么几个阶段用户输入进入 → 前置处理清洗、改写、意图识别→ 检索或记忆召回 → 提示词组装 → 模型推理 → 输出解析 → 工具调用可能多轮→ 后处理与返回。每一个箭头都是一个潜在的故障注入点。我习惯把故障面分成三大类。第一类是基础设施类包括网络延迟、超时、连接拒绝、限流这类和传统服务共通。第二类是数据与上下文类包括检索结果污染、记忆写入错误、上下文截断、文档过期这是 LLM 应用独有的重灾区。第三类是模型与协议类包括输出格式漂移、幻觉、工具调用参数畸形、多轮状态丢失。很多团队只测第一类后两类几乎裸奔结果线上出的全是后两类的锅。2.2 为什么传统故障注入工具不够用有人会问用现成的混沌工程平台注入网络延迟不就行了不够。那些工具擅长处理“请求失败”这种二元结果但 LLM 应用的故障往往是语义层面的。比如你注入 200ms 延迟模型照样能答对但你往检索结果里塞一篇语义相近但结论相反的文档模型可能就答错了而且答得理直气壮。这种故障用延迟、错误率这些指标根本捕捉不到。所以 LLM 的混沌工程需要一套新的“断言体系”。传统服务看 P99 延迟和错误率LLM 应用得看答案正确率、引用准确率、格式合规率、工具调用成功率这些语义指标。注入故障后你要对比的是这些指标的变化而不是简单的 up/down。故障类别传统服务关注LLM 应用额外关注网络延迟P99 延迟、超时率是否触发降级、降级后答案质量依赖失败错误率、熔断状态是否有兜底回答、兜底是否误导数据污染数据一致性答案正确率、引用来源可信度输出异常解析失败率格式合规率、幻觉率、工具参数合法性2.3 注入之前必须先定义“抗造”的标准这是最容易被跳过、也最致命的一步。你得先明确什么叫“扛住了”我的做法是给每个关键场景定义一组可量化的韧性指标。比如知识库问答我会定义注入单篇污染文档后答案正确率下降不超过 5%注入检索超时后系统必须在 3 秒内返回一个明确的“暂时无法回答”而不是硬编。没有这些基线注入完你根本不知道结果是好是坏。这些基线最好来自一次干净的“对照组”压测。先在不注入任何故障的情况下跑一遍完整测试集记录各项指标这就是你的基准线。后面所有注入实验都跟这条线比。我一般会准备 50 到 200 条覆盖核心场景的测试用例太少没有统计意义太多跑一次成本太高。3. 用 Python 搭一套可复用的故障注入骨架3.1 注入点的设计装饰器还是中间件实现层面我强烈建议用装饰器 配置驱动的方式而不是把注入逻辑硬编码到业务代码里。原因很简单注入代码必须能一键开关且不能污染生产路径。我见过有人直接在检索函数里写if CHAOS: return []结果上线忘了关检索直接废掉。我的做法是定义一个chaos_inject装饰器通过环境变量或配置中心控制是否启用注入类型和参数也全部外置。业务函数完全无感知只在被装饰的位置多了一层包装。这样测试环境和生产环境用的是同一份代码只是配置不同。import functools import random import time import os CHAOS_ENABLED os.getenv(CHAOS_ENABLED, false).lower() true CHAOS_CONFIG {} # 从配置中心或文件加载 def chaos_inject(point_name): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): if not CHAOS_ENABLED: return func(*args, **kwargs) rule CHAOS_CONFIG.get(point_name) if not rule: return func(*args, **kwargs) return apply_fault(rule, func, *args, **kwargs) return wrapper return decoratorapply_fault根据规则类型分发延迟类就time.sleep异常类就抛指定异常数据污染类就改写返回值。关键是每个注入点都要有独立的开关和概率不要全局一刀切。我通常把概率设在 0.1 到 0.3 之间太高会让整个测试集全是故障样本失去对照意义。3.2 故障类型的分层实现延迟注入最简单但要注意别用固定值真实世界的延迟是抖动的。我用正态分布或对数正态分布来模拟均值设成依赖服务 P99 的 1.5 倍左右。异常注入要区分“可重试异常”和“致命异常”前者模拟瞬时抖动后者模拟依赖彻底挂掉两者触发的降级逻辑应该不同。数据污染注入是最有技术含量的。以检索为例我实现了三种污染模式替换把召回文档换成语义相近但错误的、混入在正确结果里插入干扰项、截断只返回部分结果。这三种对模型的影响完全不同替换最容易导致错误答案混入考验模型的辨别能力截断考验兜底逻辑。def pollute_retrieval(docs, modemix, ratio0.3): if mode replace: return [generate_adversarial_doc(d) for d in docs] if mode mix: n max(1, int(len(docs) * ratio)) return docs [generate_noise_doc() for _ in range(n)] if mode truncate: return docs[: max(1, len(docs) // 2)] return docs3.3 让注入可观测每次实验都要留痕注入不是目的拿到数据才是。我在每次注入时都会往一个实验日志里写一条记录实验 ID、注入点、故障类型、参数、时间戳、请求 ID。这样后面分析时能把“哪次请求被注入了什么”和“这次请求的最终质量”关联起来。没有这层关联你只能看到整体指标波动无法定位是哪种故障导致的。我还会给每次实验打上标签比如exp_20240512_retrieval_pollution方便在追踪系统里过滤。实测下来这套留痕机制能省掉大量“这个指标为什么掉了”的扯皮时间。4. 检索与记忆环节的“投毒”实战4.1 检索污染最容易被低估的故障源检索是 RAG 应用的命门也是投毒效果最显著的地方。我做过一组对比实验同样一个问题检索结果完全正确时模型答对率 92%混入 30% 干扰文档后掉到 71%全部替换成对抗文档后直接掉到 34%。这个衰减曲线非常陡说明检索层的韧性直接决定了整个应用的韧性。对抗文档的构造有讲究。最粗暴的是随机文本但模型往往能识别出无关内容而忽略。真正危险的是语义高度相关但事实错误的文档。我的构造方法是拿正确文档用另一个模型改写关键事实数字、结论、主体保持措辞风格一致。这种文档模型很难分辨会当成可信来源引用。def generate_adversarial_doc(original): prompt f保持以下文档的措辞风格和结构但修改其中的关键事实数据 使其结论与原文相反。只输出改写后的文档 {original} return call_llm(prompt)实测中我发现一个反直觉的现象干扰文档数量不是越多越糟。混入 1 到 2 篇时模型答错率最高因为少量干扰容易被当成补充信息混入太多时模型反而会察觉“这些内容互相矛盾”转而依赖自身知识或表示无法确定。所以做注入实验时比例要覆盖多个档位别只测一个值。4.2 记忆污染多轮对话里的隐形杀手带记忆的 Agent 更麻烦。记忆一旦被写入错误信息后续所有轮次都会被污染而且用户很难察觉是哪一轮出的问题。我设计过一种注入在 Agent 调用记忆写入工具时悄悄篡改写入内容的一个字段。比如用户说“我住在北京”写入时改成“我住在南京”。后面所有涉及地理位置的回答都会错但表面看逻辑自洽。排查这类故障特别费劲因为错误是累积的。我的经验是给记忆系统加一层写入校验关键字段写入前做一次回读比对不一致就告警。同时在混沌实验里专门测“记忆被污染后系统能否在后续轮次自我纠正”。大部分系统做不到自我纠正这恰恰是需要加固的地方。注意记忆污染实验一定要在隔离环境做别拿真实用户数据开玩笑。我一般用合成对话数据集每个实验跑完直接销毁。4.3 上下文截断长对话的韧性测试上下文窗口是有限资源长对话必然面临截断。截断策略选得不好会把关键信息丢掉。我测试过几种策略从头截、从尾截、按重要性截。结果发现按重要性截在正常情况下最好但在注入“重要性评分错误”的故障后表现反而不如简单的滑动窗口。这个实验给我的启发是越复杂的策略故障面越大。如果你的截断逻辑依赖一个模型打分那这个打分本身就可能被注入故障。混沌工程在这里的价值就是帮你发现“聪明策略”的脆弱性从而决定是否值得为那点性能提升承担额外风险。5. 模型输出与工具调用环节的韧性考验5.1 输出格式漂移解析器的噩梦只要你的应用依赖结构化输出JSON、XML、特定标记格式漂移就是必测项。我模拟过几种漂移字段缺失、类型错误、多余包裹文本、嵌套层级错乱。最坑的是模型偶尔会在 JSON 前后加一句“好的这是结果”导致json.loads直接失败。应对这类故障解析器必须做容错。我的做法是先用正则提取最外层的大括号或方括号再尝试解析失败则走修复流程比如让模型重新输出。混沌实验里我会把漂移概率设到 0.2观察解析失败率和修复成功率。如果修复成功率低于 90%说明你的解析层还不够健壮。import json import re def robust_parse(text): match re.search(r(\{.*\}|\[.*\]), text, re.DOTALL) if not match: raise ValueError(no structured content found) try: return json.loads(match.group(1)) except json.JSONDecodeError: return repair_and_parse(match.group(1))5.2 工具调用参数畸形Agent 的常见翻车点Agent 调用工具时参数由模型生成天然不可靠。我注入过这些故障参数类型错误该传 int 传了 str、必填参数缺失、参数值超出合法范围、工具名拼写错误。每一种都会导致工具执行失败但失败后的处理逻辑差异很大。好的 Agent 应该在工具失败后能重试或换参数而不是直接把错误抛给用户。我在实验里会统计“工具调用失败后Agent 自主恢复的比例”。这个指标低于 70% 的话说明你的 Agent 容错逻辑需要加强。常见的加固手段包括给工具定义严格的 schema、在提示词里强调参数格式、失败后把错误信息回灌给模型让它修正。5.3 幻觉注入最难量化但最该测的幻觉没法像格式错误那样直接注入但可以间接诱发。我的方法是往上下文里塞入看似权威但实际不存在的信息比如伪造一条“根据 XX 规定第 3 条”观察模型是否会不加验证地引用。如果模型照单全收说明它缺乏对来源的批判性。量化幻觉率需要一套评判机制。我一般用另一个模型做 LLM as Judge对答案的事实性打分同时人工抽检校准。注入实验里我会对比“干净上下文”和“污染上下文”下的幻觉率差异。差异越大说明模型对上下文的依赖越强、自身知识越弱这类应用在检索出问题时风险最高。6. 从实验结果到系统加固的闭环6.1 怎么读实验数据别只看平均值跑完一轮注入你会得到一堆指标。新手最容易犯的错是只看平均值。平均值会掩盖长尾问题。我习惯看分位数和分布P50 可能没变但 P95 大幅恶化说明少数请求被故障打得很惨。LLM 应用里这些长尾往往就是用户体验的崩点。我还会做故障类型与指标变化的交叉分析。比如发现“检索替换”对正确率影响最大“格式漂移”对解析失败率影响最大。这样加固时就能分清主次把有限精力投到影响最大的故障类型上。6.2 加固手段的优先级排序根据多轮实验我总结出一个加固优先级兜底 校验 重试 优化。兜底是指任何环节失败都要有明确的降级输出绝不能让用户看到原始异常。校验是指在关键节点做一致性检查比如检索结果和问题相关性打分、工具参数合法性检查。重试只对瞬时故障有效对数据污染无效别滥用。优化是最后一步在保证韧性的前提下再谈性能。这个顺序很重要。我见过团队一上来就优化检索算法结果兜底逻辑一塌糊涂一次依赖抖动就导致大面积报错。先把兜底和校验做扎实系统的下限就守住了。6.3 把混沌实验纳入日常流水线一次性实验价值有限真正的价值在于持续注入。我把混沌实验做成了 CI 流水线的一个阶段每次发版前自动跑一轮注入测试对比基线指标超过阈值就阻断发布。这样能防止新代码引入韧性退化。流水线里的注入要控制成本我一般只跑核心场景的快速子集全量实验放在 nightly 任务里。阈值设定要留余量别因为正常波动就频繁阻断那样团队会很快失去耐心。我的经验是阈值设在基线指标的 10% 到 15% 衰减区间比较合理。7. 几个我踩过的坑和对应心得第一个坑是注入太猛导致实验环境雪崩。早期我把延迟注入设成固定 30 秒结果并发一上来线程池全被占满整个测试环境挂了实验数据也没拿到。后来改成基于分布的小延迟并且给注入加并发上限才稳定下来。教训是注入强度要渐进别一上来就拉满。第二个坑是用生产数据做污染实验。有次图省事直接拿线上检索库做替换实验虽然只是读操作但污染文档被缓存进了共享层影响了其他测试。从那以后我坚持所有污染实验用独立的索引副本实验完直接删。第三个坑是指标定义太模糊。一开始我用“答案看起来对不对”这种主观判断不同人结论不一样实验没法复现。后来改成明确的规则答案必须包含指定关键事实、引用必须来自白名单文档、格式必须通过 schema 校验。指标一旦客观化实验的可信度立刻上来了。最后一个心得是关于团队协作。混沌工程不是一个人的事得让开发、测试、运维都参与进来。我现在的做法是每次实验前开个短会明确这次要验证什么假设、注入什么、看什么指标。实验后一起看数据、定加固方案。这样大家对系统的韧性有共同认知加固措施落地也快得多。这套东西跑了大半年我们那个知识库问答系统的线上事故率降了七成多最关键的是团队对“系统什么时候会坏、坏了会怎样”心里有底了。给 AI 下毒这件事做多了你会发现它其实是在帮你把系统的底摸清楚。