最近一直在做智能体相关的生产级项目,越做越觉得,单次调用大模型就像让一个很聪明但偶尔走神的员工去处理关键业务——大部分时候没问题,可一旦走神,代价可能远远超出你的预期。我见过太多这类场景:Agent 自信满满地执行完一整套流程,最后给出一个错得离谱的结论,而下游系统根本不设防,直接把这个错误结果当真。今天想聊的设计模式,就是专门对付这种"自信的错误"的:冗余执行。
冗余执行不是我发明的概念,它在分布式系统里存在了很多年,但套到 AI 智能体上,玩法完全不同。传统冗余是为了防硬件故障、网络抖动,而在智能体这里,冗余是为了对抗模型本身的随机性和不确定性。简单说,就是同一个任务不让模型只做一遍,而是做多遍,再用一种合理的机制合并结果。听起来很直白,但真正落地时,你会发现从策略选择到成本控制,从结果仲裁到异常处理,每一步都是坑。
这篇文章我会从一次实际事故讲起,把冗余执行要解决的根问题说清楚,再拆解三种主流实现路径,给出能直接抄作业的代码设计,最后把成本账和踩坑心得一并摊开。适合正在做多智能体系统、或任何依赖大模型做关键决策的开发者参考。
1. 从一次试运行事故说起:智能体为什么需要"多问一次"
1.1 事故现场:一次数据清洗命令的"自信"
某个周末,某业务方做完数据专项清洗,运维同事发现线上对账表少了一整片用户。查了下操作日志,Agent 在答疑环节把一个过滤条件理解反了——"排除最近30天活跃用户"被理解成了"只保留最近30天活跃用户",于是一大批本该保留的沉默用户被批量标记为失效。更麻烦的是,Agent 在执行前还输出了"已确认过滤逻辑无误"。
这个案例里没有任何硬件故障,没有网络波动,不涉及外部依赖,纯粹是模型在语义理解上出了偏差。如果当时这步操作之后有一个人工复核环节,问题也许能挡住,但如果是全自动链路呢?这就是单次执行在智能体场景中最大的软肋:大模型的输出本质是采样,不是计算。同一个 Prompt 丢进去,温度设为 0 也不代表结果必然唯一,只是概率空间收窄了而已。
1.2 单次执行的三大不可靠来源
我把日常碰到的不可靠来源归成三类:
- 语义误解:模型对用户指令的槽位解析错误,或者对否定词、条件边界理解偏差。这是最常见的,也是上面事故的原因。
- 幻觉补全:当上下文里缺少某个关键字段时,模型会倾向于"填一个看起来合理的默认值",而且不会主动承认自己不知道。
- 工具调用顺序漂移:多轮 Tool 调用时,模型可能跳过某个前置检查,或者把两个接口的执行顺序搞反。
这三类问题有个共同特征:它们不是偶发的异常(Exception),而是看起来非常正常的错误输出。代码层面没有报错,日志看起来完整,模型甚至还带着高置信度。如果系统只在异常层面做防御(比如 try/catch、重试),根本接不住这种错误。
1.3 什么是冗余执行:不是傻乎乎地重试
很多第一次接触这个概念的朋友会问:冗余执行不就是"做两次,不一样就再做一次"吗?不是。重试是在失败前提下的重复,而冗余执行是在没有明确失败信号时,主动引入多路独立判断来交叉验证。
我习惯用一个生活类比:去机场值机,工作人员会打印行李条,然后让你看一眼大屏幕确认航班号和目的地。这个"让你自己确认一眼",就是冗余执行里的验证环节。它不关心打印这个动作有没有抛异常,它关心的是打印出来的结果本身是否可信。智能体的冗余执行也一样,不问"执行成功了吗",而是问"多个独立判断是否指向同一个结论"。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三种冗余策略的选型逻辑:平行、投票、生成-验证
落地冗余执行时,先别急着写代码,得先选型。我实际用过三种比较成熟的策略,分别适用不同形态的任务。
2.1 平行冗余:同一能力多次独立采样
平行冗余是最直接的思路:把同一个任务丢给同一个模型(或同一组配置)跑 N 次,然后看结果的一致性。注意"独立性"是这个策略的灵魂。每次调用尽量做到彼此隔离——独立的上下文快照、独立的随机种子、甚至不同的温度参数。
但平行冗余有个天生缺陷:如果模型本身存在系统性偏差,那么你跑再多遍也只会得到同一个错误。比如你让它判断某种边缘 case 是否合规,模型因为训练数据的原因对这类 case 有稳定偏见,那跑 5 遍会得到 5 个一样的错误结论。所以平行冗余适合"随机性占主导"的任务,比如代码生成、文案撰写、SQL 查询生成——这些任务里的错误更多是采样噪声,而不是系统性偏见。
实践中我发现,温度设为 0.7 到 1.0 时,平行冗余的收益最高。温度太低(比如 0),多次采样结果高度相似,交叉验证的意义被大幅削弱;温度太高(比如 1.5),结果发散到难以合并,仲裁成本反而上去。
2.2 投票冗余:多数表决的应用边界
投票冗余是平行冗余的升级版:跑 N 次之后,不是简单地看一致性,而是做多数表决。它尤其适合三类任务:
- 分类与意图识别:判断用户问题属于哪个业务域。
- 信息抽取:从一段文本里抽取关键字段,比如金额、日期、实体。
- 工具路由决策:决定下一步调用哪个工具/接口。
投票机制的优点是抗随机错误能力强,缺点是"N 个结果各说各话"时往往很难仲裁。所以实现投票冗余时必须配套一个关键参数:最低一致阈值。比如 3 次执行里,至少 2 次结论相同才采纳;如果 3 次结果都不同,宁可走兜底流程,也不要硬选一个。
我踩过另一个细节:投票时不能只对最终答案投票,还要对"关键中间步"投票。比如一个 SQL 生成任务,最终 SQL 文本可能因为格式化、别名命名不同而不同,但如果 FROM 表名错了,那最终再像样也得判死刑。所以我的投票器通常同时提取"语义指纹",把关键的表名、列名、操作类型抽取出来单独比对。
2.3 生成-验证冗余:让第二个智能体当"审查员"
生成-验证是最接近人工复核的机制。一个智能体负责生成答案,另一个智能体不负责重新生成,而是带着批判性视角去检查前者的产出。为什么要分角色?因为同一个模型的"生成能力"和"验证能力"往往是独立的。
比如代码生成场景,让模型生成一段 Python 脚本,然后用另一个 Prompt 让模型扮演资深代码审查员,专门找边界条件、类型转换、资源泄漏的问题。由于验证过程不共享生成的采样历史,它更可能发现生成阶段被忽略的细节。
这里有个关键设计:验证器不应该只说"通过"或"不通过",而应该输出"不通过时的具体原因 + 修改建议"。如果验证器只返回一个布尔值,那它自身的幻觉问题又会成为新的故障源。有了原因和建议,开发者至少能人工追溯,知道这一票为什么被否决。
生成-验证冗余的缺点是费用加倍,而且验证器的通过率如果设计得太严格,会导致大量正常结果被驳回。我见过一个团队把验证器的驳回率调到 70%,结果核心流程基本跑不动。合理的做法是通过小样本标定,把验证器的"误杀率"控制在 5% 以内。
三种策略不是互斥的,可以组合。比如我现在的生产系统里,意图识别用平行冗余 + 投票,敏感操作前的 SQL 生成用生成-验证,而高风险的数据删除操作则是三种全上。下一节就给你看一个完整的投票式冗余执行器的实现。
3. 手写一个投票式冗余执行器:完整设计与实现
3.1 需求定义与接口设计
在写代码之前,先把需求定义清楚。我需要实现一个通用的冗余执行器,它接收一个 Task 描述,返回一个带置信度的最终结果。设计约束如下:
- 并发执行 3 个独立调用,互不阻塞。
- 对返回结果做语义归一化,再进行多数表决。
- 支持自定义语义指纹提取函数(不同任务指纹不同)。
- 设置一致阈值和兜底策略,避免仲裁不出结果时系统卡死。
- 记录每次调用的元信息,便于事后排查。
这个执行器的核心价值不在于"调用 3 次",而在于"如何可靠地判断这 3 次结果是否真的等价"。
3.2 核心代码实现
下面是我在模拟项目 X 里用的一个简化版实现。完整生产版还会接配置中心和链路追踪,这里只保留主干逻辑。
python复制# executor.py
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from typing import Callable, Any, Optional
import hashlib
@dataclass
class RedundantResult:
final_answer: Any
confidence_level: str # high / medium / low
votes: list # 每次调用后的指纹统计
raw_outputs: list # 原始输出,便于排查
class VoteRedundantExecutor:
def __init__(
self,
invoke_fn: Callable[[], Any],
n: int = 3,
threshold: float = 0.67,
fingerprint_fn: Optional[Callable[[Any], str]] = None,
timeout_each: int = 30
):
self.invoke_fn = invoke_fn
self.n = n
self.threshold = threshold
self.fingerprint_fn = fingerprint_fn or self._default_fingerprint
self.timeout_each = timeout_each
def _default_fingerprint(self, output: Any) -> str:
# 默认指纹:字符串做 hash。复杂场景需要由任务方自定义。
return hashlib.md5(str(output).encode()).hexdigest()
@staticmethod
def _is_semantically_equal(fp1: str, fp2: str) -> bool:
return fp1 == fp2
def execute(self) -> RedundantResult:
pool = ThreadPoolExecutor(max_workers=self.n)
futures = [pool.submit(self.invoke_fn) for _ in range(self.n)]
raw_outputs = []
fingerprints = []
for fut in as_completed(futures):
try:
out = fut.result(timeout=self.timeout_each)
raw_outputs.append(out)
fingerprints.append(self.fingerprint_fn(out))
except Exception as exc:
# 单路异常不能直接让整体失败,记录后降级
raw_outputs.append(f"<error: {exc}>")
fingerprints.append(f"<error:{hashlib.md5(str(exc).encode()).hexdigest()}>")
pool.shutdown(wait=False)
# 统计指纹出现次数
vote_count = {}
for fp in fingerprints:
vote_count[fp] = vote_count.get(fp, 0) + 1
best_fp = max(vote_count, key=vote_count.get)
best_count = vote_count[best_fp]
agreement_ratio = best_count / self.n
if agreement_ratio >= self.threshold:
confidence = "high" if agreement_ratio >= 0.8 else "medium"
best_idx = fingerprints.index(best_fp)
final_answer = raw_outputs[best_idx]
else:
# 无法达成一致:返回 None 并标记低置信,由上层策略决定是否重跑或人工介入
confidence = "low"
final_answer = None
return RedundantResult(
final_answer=final_answer,
confidence_level=confidence,
votes=[(fp, cnt) for fp, cnt in vote_count.items()],
raw_outputs=raw_outputs
)
# 调用示例
if __name__ == "__main__":
import random, time
def fake_llm():
time.sleep(random.uniform(0.1, 0.5))
# 模拟意图识别结果
candidates = ["退款", "退货", "退款", "退款", "人工客服"]
return random.choice(candidates)
def intent_fingerprint(output: str) -> str:
# 对意图识别来说,输出字符串本身可以直接做指纹
return hashlib.md5(output.encode()).hexdigest()
executor = VoteRedundantExecutor(
invoke_fn=fake_llm,
n=3,
threshold=0.67,
fingerprint_fn=intent_fingerprint
)
result = executor.execute()
print(result.final_answer, result.confidence_level, result.votes)
这段代码里我想特别强调三点。
第一,并发是必须的。 如果串行调用 3 次大模型,总延迟是 3 倍,用户根本等不起。用 ThreadPoolExecutor 并行发起后,整体耗时约等于最慢的那一次,而不是三次之和。当然,前提是上游接口允许并发。有些企业内部的模型服务有并发配额限制,这时候需要把 max_workers 调整到服务方允许的值。
第二,单路异常不能直接让整体失败。 当前实现里某一路超时或抛错,它的指纹会被记成 <error:xxx>,虽然大概率不会获得多数票,但也保证了其余两路如果一致,系统照样可以返回结果。如果三路里两路都异常,那就是真的故障了,这时 agreement_ratio 会低于阈值,走低置信分支。
第三,指纹函数必须按任务定制。 我用 MD5 只是演示,真实场景直接用字符串哈希会出事。比如代码生成任务,模型生成的 SQL 可能在关键字大小写、缩进上有差异,文本完全不同但语义等价;而反过来,两个文本看起来一样,但语义可能不同(后面坑二会详细讲)。所以线上系统的指纹函数一般会做归一化:去掉空白、统一大小写、抽取 AST 结构、或者让一个轻量分类模型输出语义向量,然后算余弦相似度。
3.3 关键参数调优与兜底逻辑
投票式执行器有三个参数值得花时间标定:
-
N(执行次数):3 是性价比通常最高的选择。2 次容易平票,4 次的边际收益很低,费用却多 33%。只有在单次调用成本足够低且延迟要求不高时,我会考虑 5 次。还有一个办法是"动态 N":先跑 3 次,如果 3 次里已经有 2 次一致且置信度高,就停止;如果三票都不一样,再补跑 2 次。这种方式在"随机分歧高"的任务里能省不少钱。
-
阈值 threshold:0.67 意味着 3 票里至少有 2 票一致。但如果任务风险极高,我会把阈值提到 0.8。注意 3 次执行下 0.8 意味着必须 3 票全一致,否则就走兜底。这可以用在删除类操作、转账类操作上。
-
超时 timeout_each:不能只看总链路超时。大模型调用的 P99 延迟有时候会是 P50 的三倍。如果总超时设定为单次正常延迟的期望值,并发执行时慢的那一路会频繁超时,导致一致性判断被污染。我通常会把
timeout_each设为单次调用允许的最大延迟,而不是平均延迟。
兜底逻辑我一般设计四层:低置信结果先做"清晰化处理"(比如提取关键词交给规则引擎再查一次);依然不一致就降级到单次模型结果但要明确标记"低置信";如果业务链路允许,就直接转人工审核;最差情况是拒绝执行并报警。报警阈值也要专门设置——不是低置信就报,而是连续 5 分钟内低置信占比超过某个水位才报,避免告警风暴。
4. 冗余的成本账:令牌、延迟与收益边界
4.1 成本公式:冗余倍数不是白送的
冗余执行最直接的代价是 token 费用。假设单次任务调用平均消耗 2000 token(输入 1500 + 输出 500),模型单价按某国产商用模型的中档价位估算,千 token 成本约 0.03 元,那单次成本是 0.06 元。三路冗余就是 0.18 元,如果再加一路验证器,就是 0.24 元。听起来不多,但放到日调用量 100 万次的系统里,一天多出来的成本就是十多万。这不是可以忽略的数目。
所以我在项目中框定成本时,习惯用一个公式做预判:
code复制单次冗余成本 = (输入token + 输出token) × 冗余路数 × 单token单价
路数是 3 时,成本直接变成 3 倍;验证器模式是 2 倍。这里还没算延迟排队带来的资源占用和人工介入成本。这也就是说,冗余执行不能盲目全链路铺开,必须有选择地用在刀刃上。
4.2 延迟与并发控制
延迟方面,平行冗余并发的总耗时接近最慢的一次调用,所以理论上可以控制在单次调用的延迟水位。但实际没那么理想,因为多数模型服务在小并发下可能出现队头阻塞,尤其是输入 prompt 很长时。
我做压测时发现,对某一家的服务,单路 P50 是 800ms,并发 3 路后整体 P95 就飙到 2.3s。这是因为服务端的并发处理能力有限,多路请求同时到达时互相排队。解决方案是把三路请求错峰发出,比如每路间隔 200ms,或者改用异步模型服务队列。另一个通用建议是给 prompt 设置合理的 max_tokens,不要给模型过大的输出空间。输出越长,采样阶段耗时越长,这和冗余叠加起来会放大延迟问题。
4.3 建议场景矩阵:低成本高收益的切入点
到底哪些环节值得上冗余?这是我经常被问的问题。我自己的判断标准有三条:
- 错误发生后是否不可逆:比如删除数据、发消息、转账、修改线上配置。这类必须上冗余。
- 错误发生后是否难发现:比如内容审核里的漏判,答错一道题用户可能立刻发现,但漏审一条违规内容可能要很久之后才被举报。这类建议上验证式冗余。
- 用户是否愿意等待:如果业务链路要求 1 秒内返回,那 3 路冗余再快也会超时,这种情况下不如把冗余放在"预判阶段",而不是"最终响应阶段"。
反过来,下面这些场景我会劝你不要上冗余:高频且错误影响小的问答、纯聊天娱乐场景、模型的随机性本身是产品体验一部分(比如写诗、头脑风暴)。这些场景用冗余只会增加成本,收益趋近于零。
我还总结了一张参考矩阵,平时给系统做设计时直接对着套:
| 场景类型 | 推荐策略 | 冗余路数 | 说明 |
|---|---|---|---|
| 数据删除/更新 | 平行+多点验证 | 3次 | 极低容错,必须有兜底人工 |
| 意图识别/路由 | 投票冗余 | 3~5次 | 阈值设置为0.7以上 |
| 代码/SQL生成 | 生成-验证 | 2次+验证器 | 验证器要返回具体原因 |
| 内容摘要 | 无需冗余 | 1次 | 除非摘要直接影响交易决策 |
| 工具调用链 | 关键节点冗余 | 按需 | 不要全链路冗余 |
5. 五个真实踩坑现场:根因、复现与解法
5.1 坑一:三个模型"默契地"输出了同一个错误答案
这是我最早踩的坑。当时做意图识别,3 路冗余跑得很欢,一致率一直挺好,我就放心了。结果某次模型服务版本升级后,三路结果全部一致,但一致地错——把所有"改地址"的请求统统识别成"退订"。
根因是平行冗余只能对抗随机噪声,对抗不了系统性偏差。三个并发调用用的是同一个模型版本、同一个训练分布,如果这个版本本身在某个槽位上理解有偏,冗余只是在重复偏差。
解法是把"多样性"做出来。我现在会刻意让冗余路数使用不同的模型配置:比如一路用温度 0.3,一路用温度 1.0;或者一路用通用模型,一路用垂直领域微调模型。更彻底的是引入一个完全不同的模型供应商做交叉验证。如果预算不允许,至少保证 Prompt 的措辞在每路里略有差异,降低"同一条 Prompt 反复触发同样误解"的概率。
5.2 坑二:结果文本相同,语义却完全不同
这是仲裁逻辑里的隐形炸弹。有一次做信息抽取,抽"转账金额",两路都输出了"3000",第三路输出了"3000 元"。我最初的指纹函数直接对字符串 hash,结果前两路判为一致,第三路被当作异议。看起来没问题,可后来发现前两路里的"3000"一个是人民币,一个其实是美元符号被转录丢了,语义完全不同,投票却把它们当成同一个答案。
指纹函数不能只做文本归一化,得按任务的语义关键域提取指纹。对金额,指纹必须包含币种字段;对地址,指纹必须包含省市区三级;对时间,指纹必须包含时区。更保险的做法是让结构化抽取器先输出 JSON,然后只对 JSON 里参与业务决策的字段做指纹比对,忽略格式噪声字段。
另外,两个文本完全一样也不代表一定等价。我遇到过模型在推理过程中用了错误假设但输出了表面上正确的结论。这种深层语义问题靠投票解决不了,只能靠验证器把关推理过程。
5.3 坑三:并行执行变成了串行排队
我调试一个延迟问题,发现三路调用日志里的时间戳几乎一模一样,没有并发的样子。查下来发现是底层 SDK 的连接池大小只有 1,三个线程在等同一个连接释放。这不是模型服务的问题,是我们自己基础组件的瓶颈。
这类坑在容器化部署里尤其容易出。ThreadPoolExecutor 只是把任务丢给了线程,但如果每个线程里拿到的 HTTP 连接是共享的单例,阻塞就在所难免。检查点有两个:一是连接池最大连接数必须大于并发数;二是如果走的是 gRPC 长连接,要确认 stream 是否支持并发复用。
我还犯过另一个低级错误:在 Python 的 asyncio 环境里用 ThreadPoolExecutor 做阻塞调用,把事件循环卡住了。现在我的原则是,如果原始代码在 async/await 上下文,就改用 asyncio.gather 发并发请求,在线程池和协程之间别混着用。
5.4 坑四:共享上下文导致冗余执行失去独立性
冗余执行的一个前提是"各路互不打扰"。但真实系统里,Agent 往往带着一整段历史对话去调用模型。如果 3 路请求都引用了同一个可变对象来拼接上下文,而其中一路因为慢而没赶上上下文快照的复制,就可能出现两路用的是旧上下文、一路用了新上下文的情况。
这个坑的典型表现是:一致率突然从 90% 掉到 50%,查代码发现"上下文对象"是全局缓存,被某个清理协程改了。解决方法是进入冗余执行器之前,强制做一次上下文的深拷贝,并把对话轮数、工具调用记录全部快照进一个不可变结构。如果你用的是系统 Prompt + 用户消息的结构,正确做法是在冗余执行器内部各自拼接 Prompt,而不是外部共用同一个已经拼接好的字符串。
5.5 坑五:超时风暴与重试雪崩
最后一个坑和外部依赖相关。某次上游模型服务出现大规模拥堵,单路 P99 延迟从 1s 涨到 8s。我的执行器配置的 timeout_each=5s,于是三路全部超时,走了低置信兜底。兜底逻辑里的"自动重试一次"又触发了新一轮三路并发,导致下游压力进一步加大。这就是重试雪崩。
标准解法是给冗余执行器加一个"断路器"。记录最近一分钟内的超时率和低置信率,如果超过阈值就直接短路——不让模型调用进入,而是返回服务拥挤提示或走规则引擎兜底。更重要的是重试策略要用"指数退避 + 抖动",别让所有请求在同一时刻重试。我现在还会把冗余执行器的超时时间做成动态的,根据上游健康度自动调整,避免上游稍微一慢我们立刻超时误判。
写完这五个坑,我还是忍不住感叹一句:冗余执行看起来只是"多跑几次模型",但真正把它变成高可靠系统的一部分,功夫大部分都在"一致性判断"和"异常处理"上。看你敢不敢把这些细节抠到位。
6. 最后分享一点个人实操体会
做了几个月冗余执行之后,我的最大体悟是:冗余不是越多越好,而是越"独立"越好。三个同一个模型、同一条 Prompt、同一个温度参数的调用,约等于一个人把同一道题抄了三遍——不叫冗余,叫自我安慰。真正的冗余要追求决策者的多样性,不管是模型版本、参数配置、还是 Prompt 结构,差异才是价值的来源。
我个人的线上系统里,现在坚持三个原则:第一,只有"不可逆、难发现、等得起"的操作才走冗余;第二,所有冗余执行的低置信分支都会被记录到独立的统计口径,用来反向校准每条 Prompt 的质量;第三,一定会在执行器外面套一个小监控——记录"不一致率"。如果某天不一致率突然翻倍,大概率不是随机波动,而是模型版本变了或者 Prompt 被人改过。这个小技巧救过我两次,成本极低,建议你也可以试试。
冗余执行不是银弹,它挡住的是那 5% 的随机错误和 2% 的系统性偏差,但换来的可能是 99.9% 的可靠性置信度。在你真正经历一次"模型自信地搞砸关键步骤"的事故之后,你会明白这份代价值得。
