
1. 为什么我要给自家 LLM 应用“下毒”第一次听到“Chaos Engineering”这个词很多做 AI 应用的朋友会觉得离自己很远——那是运维和 SRE 团队折腾分布式系统的东西跟写 Prompt、调 RAG、接大模型 API 有什么关系我一开始也这么想直到线上出了两次事故彻底改变了我的看法。第一次是客服问答机器人。上线两周一切正常某天下午突然开始胡言乱语用户问“退款流程”它回答了一段完全无关的产品介绍还夹带了两句编造的承诺。排查了半天最后定位到是上游知识库某篇文档被误删检索层返回了空结果而我们的 Prompt 没有对“检索为空”做兜底模型直接开始自由发挥。第二次更隐蔽一个批量摘要任务平时 P99 延迟 3 秒某天突然飙到 40 秒原因是某个供应商的接口在特定输入长度下会触发重试风暴而我们的重试策略是无限指数退避直接把线程池打满。这两次事故的共同点是代码逻辑本身没 bug但系统在“非正常输入”和“依赖异常”面前毫无抵抗力。传统单元测试覆盖的是“正常路径”而 LLM 应用的脆弱点恰恰藏在那些你没想到的边界里——检索为空、上下文超长、模型返回非法 JSON、工具调用死循环、供应商限流、Token 计费突增。这些东西不主动去“下毒”你永远不知道系统会在哪里崩。所以这篇文章想聊的就是怎么把 Chaos Engineering混沌工程的思路搬到 LLM 应用上主动注入故障观察系统行为找到脆弱点然后加固。它不是什么高深理论而是一套可以落地的工程实践。适合正在做 AI Agent、RAG 系统、大模型 API 封装的开发者也适合负责 AI 应用稳定性的测试和运维同学。读完你至少能拿到一套可复现的故障注入脚本、一份 LLM 应用专属的故障清单以及几个我踩过的真实坑。2. LLM 应用的脆弱面到底长在哪2.1 和传统微服务相比LLM 系统多了哪些“软肋”传统微服务的故障模型相对清晰网络超时、服务宕机、磁盘满、CPU 打满。这些都有成熟的监控和熔断方案。但 LLM 应用在传统依赖之上又叠了一层“语义层”的不确定性这层才是最难防的。我把它拆成四类脆弱面。第一类是输入脆弱性用户输入可以是任意自然语言长度、语言、编码、注入攻击都不可控。第二类是模型输出脆弱性模型返回的不是结构化数据而是概率生成的文本可能缺字段、多字段、类型错误、甚至返回一段解释而不是 JSON。第三类是检索与记忆脆弱性RAG 依赖向量库和知识库检索质量受 embedding 模型、分块策略、索引状态影响任何一个环节抖动都会传导到最终回答。第四类是编排脆弱性Agent 会调用工具、多轮循环、并行分支一个工具返回异常就可能让整个链路卡死或跑飞。这四类脆弱面里前两类是 LLM 应用独有的后两类在传统系统里有影子但被放大了。理解这个分类很重要因为它决定了你注入故障的“靶点”在哪里。2.2 一次真实事故的完整复盘检索为空引发的连锁反应回到开头那次客服机器人事故我把完整链路还原一下你能更直观看到脆弱性是怎么传导的。用户提问“怎么申请退款” → 意图识别模块判定为“售后咨询” → 检索模块去向量库查 top-5 文档 → 向量库因为索引重建返回了空列表 → 检索模块没有对空结果做特殊处理直接拼了一个空 context → Prompt 模板里写着“根据以下资料回答”但资料是空的 → 模型面对空 context 和用户问题选择了“合理编造” → 输出了一段看似专业但完全错误的退款政策。整个链路里向量库返回空是“故障源”但真正让事故放大的是检索模块和 Prompt 层都没有对空结果做防御。如果检索模块在空结果时返回一个明确的“未找到相关资料”标记或者 Prompt 层检测到 context 为空时切换到“我不知道”的兜底话术事故就不会发生。这个案例说明一个关键点LLM 应用的故障往往不是单点故障而是“故障源 缺乏防御”的组合。混沌工程要做的就是主动制造故障源然后看你的防御层到底有没有生效。2.3 故障注入的靶点清单从输入到输出的全链路基于上面的分析我整理了一份 LLM 应用故障注入靶点清单按链路顺序排列。这份清单是我实际项目里反复用到的你可以直接拿去改。链路环节注入靶点典型故障观察指标输入层用户输入超长文本、特殊字符、注入指令、多语言混杂是否崩溃、是否被绕过预处理分块与清洗分块边界切断语义、清洗误删关键信息检索召回率变化检索层向量库返回空、返回低相关、返回重复、超时空结果处理、降级逻辑检索层Embedding维度错误、模型不可用、向量全零异常捕获、重试编排层工具调用返回非法 JSON、超时、死循环、返回超大结果循环上限、超时熔断模型层LLM API限流、超时、返回截断、返回非 JSON、内容审核拦截重试策略、解析容错输出层后处理解析失败、字段缺失、类型错误兜底话术、告警资源层Token 与成本单次请求 Token 暴涨、并发突增成本熔断、限流这张表的价值在于它把“下毒”这件事从模糊的“搞点故障”变成了可枚举、可执行的清单。你不需要一次全做挑你最担心的三五个靶点先跑起来就能发现不少问题。3. 用 Python 搭一套最小可用的故障注入框架3.1 为什么不用现成的混沌工程平台市面上有 Chaos Mesh、Litmus 这类成熟的混沌工程平台功能强大但用在 LLM 应用上有个尴尬它们擅长注入基础设施层故障网络、Pod、磁盘而 LLM 应用最需要的“语义层故障”——比如让模型返回非法 JSON、让检索返回空——这些平台很难直接表达。我的选择是基础设施层用现成平台应用层用 Python 自己写注入器。原因有三。第一LLM 应用的故障大多发生在应用代码内部用 Python 的 monkey patch 或依赖注入能精准控制。第二自己写的注入器可以复用现有的测试框架和断言逻辑不用另起一套。第三成本低、迭代快改一行代码就能加一个新故障类型。下面这套框架我用了大半年核心就三个模块故障注入器、故障配置、观测钩子。3.2 故障注入器的核心设计装饰器 配置驱动先看最核心的注入器。思路很简单用装饰器包裹目标函数根据配置决定是否注入故障、注入什么故障。import functools import random import time import json from typing import Callable, Any class FaultInjector: def __init__(self, config: dict): self.config config self.enabled config.get(enabled, False) self.faults config.get(faults, []) def inject(self, fault_type: str, **kwargs): 装饰器对目标函数注入指定类型的故障 def decorator(func: Callable) - Callable: functools.wraps(func) def wrapper(*args, **kw): if not self.enabled: return func(*args, **kw) # 按概率决定是否触发 if random.random() kwargs.get(probability, 1.0): return func(*args, **kw) return self._apply_fault(fault_type, func, args, kw, **kwargs) return wrapper return decorator def _apply_fault(self, fault_type, func, args, kw, **kwargs): if fault_type timeout: time.sleep(kwargs.get(delay, 5)) raise TimeoutError(fInjected timeout in {func.__name__}) elif fault_type empty_result: return kwargs.get(empty_value, []) elif fault_type malformed_json: return {invalid json: elif fault_type exception: raise kwargs.get(exception, RuntimeError(Injected fault)) elif fault_type truncate: result func(*args, **kw) return result[:kwargs.get(keep_ratio, 0.3) * len(result)] else: return func(*args, **kw)这段代码的关键设计点有三个。第一配置驱动enabled开关让你可以在测试环境打开、生产环境关闭避免误伤。第二概率触发probability参数支持按比例注入比如 10% 的请求触发故障更接近真实世界的偶发性。第三故障类型可扩展_apply_fault里每种故障对应一个分支加新故障只需要加一个 elif。我特别想强调probability这个设计。早期我图省事注入故障就是 100% 触发结果测试跑起来全是失败根本分不清哪些是“系统本来就有的问题”、哪些是“注入导致的”。后来改成按概率注入配合多次运行统计才能看出系统的真实韧性。3.3 故障配置的写法用 YAML 描述你的“毒药配方”注入器有了接下来是配置。我用 YAML 来管理故障配置因为可读性好非开发同学也能看懂和修改。enabled: true faults: - target: retriever.search type: empty_result probability: 0.2 empty_value: [] - target: llm_client.chat type: malformed_json probability: 0.1 - target: tool_executor.run type: timeout probability: 0.15 delay: 10 - target: llm_client.chat type: truncate probability: 0.1 keep_ratio: 0.5这份配置的意思是检索有 20% 概率返回空模型有 10% 概率返回非法 JSON工具有 15% 概率超时 10 秒模型有 10% 概率返回被截断的内容。你可以根据自己系统的薄弱环节调整这些数字。配置加载和绑定到目标函数的逻辑import yaml def load_and_bind(config_path: str, targets: dict): with open(config_path) as f: config yaml.safe_load(f) injector FaultInjector(config) for fault in config[faults]: target_name fault[target] if target_name in targets: obj, method_name targets[target_name] original getattr(obj, method_name) wrapped injector.inject( fault[type], probabilityfault.get(probability, 1.0), delayfault.get(delay, 5), empty_valuefault.get(empty_value, []), keep_ratiofault.get(keep_ratio, 0.5), )(original) setattr(obj, method_name, wrapped) return injector这里用targets字典把配置里的字符串映射到实际的对象和方法避免硬编码。实际项目里我会在应用启动时调用这个函数把注入器挂上去。3.4 观测钩子注入之后怎么知道系统发生了什么注入故障只是第一步关键是观测。我在每个关键环节都加了钩子记录输入、输出、耗时、异常。import logging import time logger logging.getLogger(chaos) def observe(step_name: str): def decorator(func): functools.wraps(func) def wrapper(*args, **kw): start time.time() try: result func(*args, **kw) elapsed time.time() - start logger.info(json.dumps({ step: step_name, status: ok, elapsed_ms: round(elapsed * 1000, 2), result_preview: str(result)[:200], }, ensure_asciiFalse)) return result except Exception as e: elapsed time.time() - start logger.error(json.dumps({ step: step_name, status: error, elapsed_ms: round(elapsed * 1000, 2), error: str(e), }, ensure_asciiFalse)) raise return wrapper return decorator观测日志用 JSON 格式方便后续用脚本做聚合分析。我一般会统计几个指标故障注入次数、系统捕获次数、未捕获导致崩溃次数、降级成功次数。这四个数字能直接告诉你系统的韧性水平。4. 五类高频故障的注入手法与系统反应实录4.1 检索返回空最容易被忽视的“沉默杀手”检索返回空是我遇到频率最高的故障也是最容易被忽视的。因为空列表不会抛异常代码会“正常”往下走直到模型开始编造。注入方式很简单用上面的empty_result类型把检索函数的返回值替换成空列表。但观察重点在于系统有没有在检索为空时做出正确反应。我实测过三种系统的反应。第一种是“裸奔型”检索为空Prompt 照常拼接模型自由发挥输出一段看似合理但完全错误的内容。第二种是“半防御型”检索为空时 Prompt 里加了一句“如果没有相关资料请说明”但模型有时候还是会编。第三种是“强防御型”检索为空时直接短路返回固定的“未找到相关资料请换个问法”根本不调用模型。从成本和准确性看第三种最稳。因为调用模型去处理空 context本身就是浪费 Token 且增加不确定性。我的建议是在检索层就做好空结果判断空结果直接走兜底不要交给模型去“发挥”。这里有个细节坑有些向量库在查询无结果时返回的不是空列表而是一个包含“低分占位符”的列表。你注入空列表能测出问题但真实场景下可能是低分结果所以注入时最好同时测“空列表”和“低分列表”两种情况。4.2 模型返回非法 JSON解析层的容错能力大考只要你的 LLM 应用涉及结构化输出比如让模型返回 JSON 格式的工具调用参数就一定会遇到模型返回非法 JSON 的情况。原因很多模型被截断、模型加了 Markdown 代码块标记、模型返回了自然语言解释、模型输出了多余逗号。注入方式是拦截模型返回替换成各种非法格式。我常用的几种“毒药”纯文本好的我来帮你查询天气带 Markdown 包裹json\n{\city\: \北京\}\n截断 JSON{\city\: \北多余逗号{\city\: \北京\,}类型错误{\city\: 123}观察重点是解析层有没有做容错。我见过最脆弱的实现是直接json.loads(response)一旦失败整个请求就 500。稍微好一点的是 try-except 后返回默认值但默认值可能掩盖问题。最稳的做法是先尝试提取 JSON 子串去掉 Markdown 标记再解析解析失败则触发一次重试带“请只返回 JSON”的修正提示重试仍失败才走兜底。这里有个经验重试次数不要超过 1 次。我试过重试 3 次结果在模型持续返回非法格式时单次请求耗时从 2 秒涨到 15 秒反而拖垮了系统。1 次重试是成本和成功率的平衡点。4.3 工具调用超时与死循环Agent 编排的熔断设计Agent 类应用最怕工具调用出问题。超时还好说加个 timeout 就行死循环才可怕Agent 可能反复调用同一个工具Token 哗哗烧任务永远不结束。注入超时用timeout类型让工具函数 sleep 指定时间后抛异常。注入死循环稍微麻烦点需要让工具返回一个“诱导 Agent 继续调用”的结果。比如一个查询工具正常返回查询结果注入后返回“未找到请换个关键词再查”Agent 就可能陷入“换关键词-还是没找到-再换”的循环。观察重点是三个有没有超时熔断、有没有循环次数上限、超时后有没有降级。我实测下来一个健壮的 Agent 编排应该有三层防护单次工具调用超时比如 10 秒、单任务总循环上限比如 10 轮、总耗时上限比如 60 秒。任何一层触发都应该优雅退出并返回部分结果而不是直接报错。有个坑我踩过循环上限设了但计数逻辑写在 Agent 主循环里而工具内部又嵌套了子 Agent 调用导致子 Agent 的循环没被计数整体还是跑飞了。所以计数要放在最外层的编排器里或者用上下文变量全局传递。4.4 上下文超长与 Token 暴涨成本熔断的必要性Token 成本是 LLM 应用特有的风险。一次请求如果上下文拼接失控可能从几千 Token 涨到几十万 Token成本直接翻百倍。注入方式是构造超长输入。比如在用户问题后面拼接大量无关文本或者在检索结果里塞入超长文档。观察重点是有没有 Token 预估和截断机制、有没有单请求成本上限、有没有并发限流。我的做法是在请求进入模型前做一次 Token 预估用 tiktoken 之类的库超过阈值就触发截断或拒绝。阈值怎么定我的经验是按业务场景定问答类单请求控制在 8K Token 以内摘要类可以放宽到 32K但都要有硬上限。同时加一个成本熔断单用户单分钟 Token 消耗超过阈值就限流防止被恶意刷。这里有个反直觉的点截断不一定从尾部截。对于 RAG 场景检索结果的相关性是从高到低排的截断应该保留高分文档、丢弃低分文档而不是简单截断字符串。我早期就是简单截断结果把最相关的文档截没了回答质量反而下降。4.5 供应商限流与内容拦截外部依赖的降级预案LLM API 供应商会限流也会因为内容审核拦截某些请求。这些不是你代码的问题但会直接影响可用性。注入方式是模拟 429限流和内容拦截响应。观察重点是有没有重试、重试策略是否合理、有没有备用供应商、拦截后有没有友好提示。重试策略我推荐指数退避 抖动但上限要设死。比如初始 1 秒每次翻倍最多重试 3 次总耗时不超过 10 秒。抖动是为了避免多个请求同时重试造成“重试风暴”。备用供应商这块如果业务对可用性要求高建议至少接两家。但要注意不同供应商的模型能力不同切换后输出质量可能变化所以切换逻辑要配合质量监控不能无脑切。内容拦截的处理容易被忽略。用户输入被拦截时应该返回明确的“该问题无法回答”而不是让系统报错。我见过有系统直接把拦截响应当成正常响应解析结果解析失败又触发重试白白浪费几次调用。5. 从“下毒”到“加固”故障注入结果怎么用5.1 建立韧性基线每次迭代都跑一遍故障注入故障注入不是一次性任务而是要纳入 CI/CD 流程。我的做法是每次发版前跑一遍故障注入测试集记录“韧性基线”。基线包含几个核心指标故障捕获率注入的故障中被系统正确处理的百分比、降级成功率降级后仍能返回可用结果的百分比、平均恢复时间从故障发生到系统恢复正常的耗时、未捕获崩溃数。这四个指标每次发版都对比一旦下降就说明新代码引入了脆弱点。跑测试集的时候要注意故障注入测试和功能测试要分开跑。因为注入故障后功能断言大概率会失败混在一起跑会导致 CI 一直红。我的做法是单独一个 job只跑故障注入断言的是“系统没有崩溃”和“降级逻辑生效”而不是“返回结果正确”。5.2 把故障场景沉淀成回归用例每次线上事故复盘后我都会把事故场景抽象成一个故障注入用例加到回归集里。这样下次有人改相关代码这个用例会自动跑防止问题复发。比如检索为空那次事故我沉淀的用例是注入检索空结果断言系统返回兜底话术且不调用模型。这个用例后来真的拦住了一次回归——有个同事重构检索模块时把空结果判断逻辑删了CI 直接报红。用例的写法建议用参数化把故障类型、注入概率、期望行为都做成参数方便批量维护。5.3 监控告警的补全让故障在发生前被看见故障注入的另一个价值是帮你发现监控盲区。很多故障在注入时你才发现“原来这个环节根本没埋点”。我的经验是每注入一类故障就问自己三个问题。这个故障如果真实发生我怎么知道监控知道之后我怎么快速定位日志和链路追踪定位之后我怎么快速恢复降级和回滚。这三个问题答不上来就说明监控告警需要补。比如 Token 暴涨这个故障我注入后才发现原来没有单请求 Token 监控只有账单层面的日统计。等账单出来钱已经烧了。后来加了实时 Token 监控和阈值告警才把这个盲区补上。6. 几个我踩过的坑和反直觉经验6.1 故障注入不是越多越好要控制“爆炸半径”刚开始我图痛快把所有故障都打开、概率拉满结果系统直接雪崩日志刷屏根本没法分析。后来学乖了每次只注入一类故障概率控制在 10%-20%在隔离环境跑。爆炸半径的控制还包括注入故障的请求要打标记方便和正常请求区分注入故障的实例要和正常实例隔离避免影响真实用户注入时间要可控跑完自动关闭。6.2 有些故障注入后系统“看起来正常”反而更危险最危险的不是系统崩溃而是系统在故障下“看起来正常”但实际输出错误。比如检索为空时模型编造内容系统不报错、不告警用户拿到错误答案还以为是真的。所以故障注入的断言不能只看“有没有报错”还要看“输出是否符合预期”。对于 LLM 输出可以用另一个模型做 judgeLLM as judge判断输出是否基于给定 context、是否有编造。虽然 judge 本身也有误差但比人工检查效率高得多。6.3 生产环境做故障注入的边界在哪里生产环境能不能做故障注入我的答案是能做但要极其克制。基础设施层的故障注入比如模拟某个实例宕机在生产环境有成熟实践但 LLM 语义层的故障注入我建议只在灰度环境做。如果一定要在生产做至少满足三个条件只对内部流量或测试账号注入、概率极低比如 0.1%、有实时熔断开关。我自己的项目里生产环境只做只读的观测不做主动注入注入全部在预发环境完成。6.4 团队协作怎么让非 AI 背景的同学也参与进来故障注入这件事如果只有 AI 工程师参与容易漏掉运维和业务的视角。我的做法是把故障清单做成共享文档让运维同学补充基础设施层的故障让业务同学补充“用户最不能接受的错误输出”场景。比如业务同学提出“如果退款政策回答错误用户可能真的去申请造成资损。”这个场景就被加进了高优先级故障集对应的断言是“退款相关问题必须基于检索结果检索为空时必须拒答”。这种跨视角的补充是纯技术团队想不到的。7. 写在最后的一点个人体会做 LLM 应用的混沌工程最大的心得是不要相信“正常情况下能跑通”就等于“系统健壮”。大模型的不确定性、检索的抖动、外部依赖的波动这些在正常测试里都被平滑掉了只有主动下毒才能暴露出来。我现在的习惯是每上线一个新功能先问自己三个问题检索为空会怎样模型返回垃圾会怎样工具超时会怎样这三个问题答不上来功能就不算做完。这套故障注入框架从最初的几十行代码慢慢长到现在覆盖五类故障、十几个靶点靠的不是一次性设计而是每次事故后往里加一个用例。如果你刚开始做建议从最简单的“检索返回空”和“模型返回非法 JSON”这两个靶点入手用上面那套装饰器框架半天就能跑起来。跑完你会发现系统里藏着的脆弱点比你想象的多得多。