AgentScope第5式·御模切换
AgentScope第5式·模型容错
模型一挂,数字人就成了哑巴——容错不该写在业务代码里。
[读者] 用 Java 做数字人 / 实时对话应用的工程师
[痛点] 模型写死在配置里,主模型一抖动整段会话就断
[现在读] Demo 已跑通,下一步必须过模型不稳这一关
[读完] 能自己搭一套 registry + 超时 + 重试 + 回退的模型层
[旧方案] 业务代码里直接 new 具体模型,模型名写死在配置
|
v
[新需求] 数字人要有主模型和备用模型,一个挂了另一个顶上
|
v
[冲突] 模型创建点散落在 Agent、Bridge、测试里,容错无处安放
|
v
[后果] 演示当天主模型限流,整条链路没有兜底,只能重启碰运气
我是老李。前四式我们把一个最小交互数字人 Demo 从架构选型、开发环境,一路推到了能跑通的状态:Spring Boot 管项目配置,Web 运行页连着 LiveKit Room,Realtime Bridge 把实时音视频流接进来,再转给 AgentScope Java 做推理。功能是通的,但它现在只有一个模型。
这一式要处理的任务是:在不动业务代码的前提下,把模型层从「一个写死的模型名」升级成「可解析、可超时、可重试、可回退」的一层基础设施。
为什么这件事不能拖?因为 Demo 一旦要见人,就一定会遇到模型侧的不确定性——限流、超时、上游抖动。这些都不是 Bug,是常态。
那问题就来了:模型容错应该写在哪一层?
写在业务代码里,还是写在模型解析层?
如果选错了层,会出现什么后果?
01、故事
那天下午两点四十,离客户到会议室还有二十分钟。我把数字人 Demo 部署到测试机,打开 /run/1,对着麦克风说了句「你好,介绍一下你自己」。
页面卡住了。三秒、五秒、十秒。LiveKit 房间还在,音频通道还在,Bridge 的日志停在最后一行:模型调用没有返回。
我第一反应是查网络,第二反应是查 API Key,第三反应是重启。重启完还是不行。再试一次,偶尔能通,偶尔超时——这是典型的限流特征。
问题是,我的代码里没有任何地方能兜住它。模型是在 AgentFactory 里 new 出来的,new 的时候就把 Key 和模型名绑死了;Agent 是在 Spring 单例里缓存的;Bridge 拿到的是一个已经「成品」的 Agent。整条链路上没有任何一个环节说「主模型不行,我换一个」。
那天我临时改配置、重启,客户演示算过去了。但我很清楚:这不是容错,这是运气。
02、问题
旧做法是这样的:application.yml 里写一个 digitalhuman.model.name,AgentFactory 读它,然后在启动时构造一个具体 provider 的模型对象,塞给 Agent 缓存起来。
这套做法在只有一个模型、模型永远在线的前提下是合理的。它简单、直接、没有抽象。但它顶不住三件事。
第一,切换模型要改代码或改配置重启。它不是「解析」出来的,是「构造」出来的。
第二,业务代码 import 了具体 provider 类。想从 DashScope 换到 OpenAI,要改的是 Java 文件,而不是一行配置。
第三,也是最要命的:没有任何一处地方知道「这次调用可能失败,失败了应该怎么办」。超时没设、重试没有、回退没有。主模型一断,整条实时会话就断。
对数字人这种场景来说,第三点的代价被放大了。文本对话断一次,用户刷新页面就好;实时语音会话断一次,是用户正说着话,屏幕上的数字人突然不动了。这个体感差得多。
所以本篇要达成的完成标准有四条,都是可以当场验证的:
一是模型标识统一成 provider:model 这种字符串形式,写在配置里,不写在代码里。
二是解析模型这件事交给 ModelRegistry,业务代码不再 import 任何具体 provider 类。
三是主模型失败时,日志里能看到重试记录;重试耗尽后能看到回退记录;最终调用方仍然拿到一份结果。
四是这套失败是我可以主动制造的,而不是等它自己发生。
03、原理
先分清三个概念,只讲到够用为止。
Model 是接口,代表「一个能接受消息、返回回复的东西」。它不管底层是 HTTP 还是本地进程。
ModelProvider 是 SPI,代表「一类模型的接入实现」。它回答的是「dashscope 这个前缀该由谁负责」。
ModelRegistry 是解析器,代表「把 provider:model 这样一个字符串,变成一个真正的 Model 实例」。它回答的是「我要一个 dashscope:qwen-max,你去给我」。
这三者的关系,是「约定 - 实现 - 查找」。业务代码只跟注册表打交道,永远不认识具体的 provider 类。AgentScope Java v2.0.3 里,注册表的源码位置在 agentscope-core/src/main/java/io/agentscope/core/model/ModelRegistry.java,官方模型能力说明在 docs/v2/zh/docs/building-blocks/model.md。这两个位置建议对照着看:前者告诉你解析入口在哪,后者告诉你各 provider 的扩展姿势。
现在说第一个反直觉判断:超时不是「配得越长越安全」,超时是模型层最重要的正确性参数。
很多人配超时的思路是「宁长勿短」,怕误杀慢请求。但在实时数字人场景里,一个没有超时的模型调用,会把整个 LiveKit 会话拖死——用户在等,音频通道在耗,Bridge 的上下文在堆积。一个卡住的请求,比一个失败的请求贵得多。失败可以回退,卡住只能等。
第二个反直觉判断:重试和回退解决的不是同一类问题,混在一个 catch 里会放大故障。
重试解决的是「瞬时抖动」:网络抖了一下、连接池抢了一下、上游偶发 503。这类失败的特征是——再试一次很可能成功,而且重试不会改变语义。
回退解决的是「能力可用性」:主模型这个通道整个不可用了,重试多少次都没用。这时候必须换一个 provider。
如果你把它们混在一起写,会发生什么?一个参数错误(比如 prompt 超长、模型名拼错)会先被重试 N 次,每次都在浪费延迟,然后才回退到备用模型,而备用模型很可能因为同样的参数问题再失败一次。用户感知到的不是「一次快速失败」,而是「一次漫长的失败」。
所以正确的分层是:先分类,再决定重试还是回退;重试内部先做退避,不要拿瞬时并发去冲击一个已经过载的上游。
第三个要提前说清楚的边界:provider:model 的解析,按约定是取第一个冒号切分。这意味着模型名本身不能再带冒号。像 Ollama 这类本地模型,通常也不涉及远程鉴权,但它的可用性依赖本机进程是否在跑——这类「本地 provider」放进回退链时要格外小心,因为它的失败模式是「进程没起来」,不是「网络抖了」。
04、架构
[输入] /run/{projectId} 会话发起 + 用户文本/音频
|
v
[模块] Realtime Bridge → ResilientModelGateway → ModelRegistry
|
v
[数据/状态] provider:model 规格、超时/重试/回退配置、会话上下文
|
v
[处理] 解析模型 → 带超时调用 → 异常分类 → 退避重试 → 主备切换
|
v
[输出] 模型回复 → Bridge → LiveKit → 浏览器
这一层加进来,边界变得很清楚。
LiveKit 依然只管 WebRTC Room 和音频通路,不认识模型。Bridge 依然是最薄的一层,它只做「把用户输入交给网关,把网关结果送回房间」。网关负责解析、超时、重试、回退,并且只暴露一个方法给上层。Agent 只消费模型,不关心模型从哪来。
收益是:模型换厂商改配置;主模型抖动有兜底;业务代码与 provider 完全解耦,测试时可以注入一个假的解析结果。
代价有三个。第一个是多了一层间接调用,出错时栈会变深,日志必须写好,否则排查反而变难。第二个是回退会带来语义漂移——备用模型的风格、长度、工具调用倾向都可能和主模型不一样,数字人的「人格」会有细微变化。第三个是重试会放大下游压力,上游已经过载时,盲目重试等于雪上加霜。
适用条件是:调用本身是幂等的读类请求,比如「给一句话生成回复」;跨模型输出差异在可接受范围内;业务能接受「这次回复可能来自备用模型」。
不适用条件是:需要严格一致性的场景(比如结构化输出必须过某个 schema 校验)、成本对重试极其敏感的场景、以及本地 Ollama 这种「失败即进程未起」的 provider 作为回退目标。
关于 AgentScope Java 内部机制,这里有必要指出:ModelRegistry 的职责是解析与查找,它不负责重试,也不负责回退。v2.0.3 的注册表源码和 model.md 文档都指向这一点——框架给你的是「从哪里拿到模型」,不是「拿到之后怎么保证它一定能用」。后者是你自己的工程问题。这也是本篇要自己写网关的原因。
05、实战一次
先建分支,从第4式继续演进:
git checkout chapter/04-agent-core
git checkout -b chapter/05-model-registry
版本基线是 AgentScope Java v2.0.3,JDK 21。下面代码未在当前环境实测,方法名请以固定 tag 源码为准,以下为按官方文档结构编写的预期实现。
第一步,配置。把模型规格从代码搬到配置文件:
digitalhuman:
model:
primary: dashscope:qwen-max
fallback: openai:gpt-4o-mini
timeout: 8s
max-attempts: 2
backoff: 300ms
第二步,定义一个异常分类器。这里刻意不依赖任何具体的框架异常类型,而是用「类名 + 消息里的 HTTP 状态码」做判断,避免跟着 provider 实现漂移:
package com.example.digitalhuman.model;
import java.util.Set;
public final class RetryPolicy {
private static final Set<Integer> RETRYABLE_STATUS = Set.of(429, 500, 502, 503, 504);
private RetryPolicy() {
}
public static boolean isRetryable(Throwable t) {
Throwable cur = t;
int depth = 0;
while (cur != null && depth++ < 8) {
String name = cur.getClass().getName().toLowerCase();
if (name.contains("timeout") || name.contains("socket") || name.contains("connect")) {
return true;
}
String msg = cur.getMessage() == null ? "" : cur.getMessage();
for (Integer code : RETRYABLE_STATUS) {
if (msg.contains(String.valueOf(code))) {
return true;
}
}
if (msg.contains("401") || msg.contains("403") || msg.contains("404")) {
return false;
}
cur = cur.getCause();
}
return false;
}
}
第三步,写网关。它是这一式唯一新增的核心组件:
package com.example.digitalhuman.model;
import io.agentscope.core.model.Model;
import io.agentscope.core.model.ModelRegistry;
import java.time.Duration;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Function;
public final class ResilientModelGateway {
private final String primary;
private final String fallback;
private final Duration timeout;
private final int maxAttempts;
private final Duration backoff;
private final ExecutorService caller = Executors.newVirtualThreadPerTaskExecutor();
public ResilientModelGateway(String primary, String fallback, Duration timeout,
int maxAttempts, Duration backoff) {
this.primary = primary;
this.fallback = fallback;
this.timeout = timeout;
this.maxAttempts = Math.max(1, maxAttempts);
this.backoff = backoff;
}
public <T> T call(Function<Model, T> invocation) {
try {
return attempt(primary, invocation);
} catch (RuntimeException primaryError) {
if (fallback == null || fallback.isBlank()) {
throw primaryError;
}
System.out.println("primary exhausted, falling back to " + fallback);
return attempt(fallback, invocation);
}
}
private <T> T attempt(String spec, Function<Model, T> invocation) {
RuntimeException last = null;
for (int i = 1; i <= maxAttempts; i++) {
try {
Model model = resolve(spec);
Future<T> future = caller.submit(() -> invocation.apply(model));
return future.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
last = new ModelCallException(spec, "timeout on attempt " + i, e);
} catch (ExecutionException e) {
Throwable cause = e.getCause() == null ? e : e.getCause();
if (!RetryPolicy.isRetryable(cause)) {
throw new ModelCallException(spec, "non-retryable failure", cause);
}
last = new ModelCallException(spec, "retryable failure on attempt " + i, cause);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ModelCallException(spec, "interrupted", e);
}
System.out.println("attempt " + i + "/" + maxAttempts + " failed on " + spec);
sleep((long) backoff.toMillis() * i);
}
throw last;
}
private Model resolve(String spec) {
return ModelRegistry.resolve(spec);
}
private void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
注意 resolve 这一行是整段代码里唯一跟框架强耦合的地方,也是这一式的核心:模型是从字符串解析出来的,不是 new 出来的。
第四步,把 Bridge 里原来直接持有的 Agent 改成通过网关调用。网关只提供一个 Function<Model, T> 的执行壳,具体怎么调模型由上层决定,这样网关不需要知道 AgentScope 的调用签名,升级框架版本时改动面被压到最小。
第五步,启动并验证:
mvn -pl digitalhuman-web spring-boot:run
正常路径的请求:
curl -s http://localhost:8080/api/run/1/say \
-H 'Content-Type: application/json' \
-d '{"text":"你好,介绍一下你自己"}'
示例输出:
{"reply":"你好,我是……","model":"dashscope:qwen-max","fallback":false}
现在主动制造失败:把 primary 改成 dashscope:qwen-max-typo,或者把主模型的 Key 改成错的。预期日志如下,这是示例输出,未在当前环境实测:
attempt 1/2 failed on dashscope:qwen-max-typo
attempt 2/2 failed on dashscope:qwen-max-typo
primary exhausted, falling back to openai:gpt-4o-mini
served by openai:gpt-4o-mini, fallback=true
验收点就是这条日志链:重试出现过,回退出现过,最终调用方拿到了结果。
06、排查
第一条证据链。现象:把主模型名字改错之后,日志里没有出现两次重试,而是直接回退到了备用模型。最初的怀疑是 max-attempts 没生效。
错误尝试: 我第一反应是去改 maxAttempts 的赋值,把它从配置读取改成硬编码为 3。这个方向是错的,因为问题不在次数读取上。
真正的检查应该从异常分类开始。模型名拼错,返回的一般是 400 或 404 这一类状态,RetryPolicy.isRetryable 会返回 false,代码走的是 throw new ModelCallException(spec, "non-retryable failure", cause),直接抛出去,被外层 call 捕获后触发回退。
证据就是日志里那句话从「retryable failure」变成了「non-retryable failure」。根因是:参数错误本来就不该重试,代码的行为是对的,我的预期错了。修复方式是修正验收脚本——制造失败要用「能触发重试的失败」,比如把主模型的 API Key 改错触发 401 之外的连接类异常,或者直接断网、把 endpoint 指到一个不存在的主机。
这条链子最有价值的地方不是修复,而是它证明了重试分类必须显式化。如果当时我没写分类,而是简单 catch 一切重试,那么一个拼错的模型名会白白浪费两轮退避延迟,用户多等两秒,还是错。
第二条证据链。现象:重试确实触发了,但两次重试都在同一时刻发出,日志时间戳几乎重合。怀疑退避没写对。检查发现退避写成了 Thread.sleep(backoff),两轮之间是等间隔的。
这在单个请求里看不出问题,但在真实并发下,如果上游是因为过载返回 503,那么所有失败的请求会在几乎同一时刻集体重试,形成一次新的脉冲,把刚缓过来的上游再次打垮。根因是缺少抖动或线性/指数增长。修复方式是把退避改成随尝试次数增长的线性退避,生产环境建议再加随机抖动。
这两条链子合起来说明一件事:模型容错的坑,绝大多数不在「会不会重试」,而在「什么时候重试、重试几次、退多久」。
上游贡献判断:这一式暂未在最小 Demo 中复现出明确的 AgentScope Java 上游缺陷。ModelRegistry 在本篇中的行为是解析与查找,重试和回退属于应用侧的工程决策,不构成框架问题。可记录的候选是文档层面——provider:model 中「模型名不能含冒号」以及各 provider 的失败特征差异,如果官方文档尚未明示,可作为文档 Issue 候选;提出前需先核对 v2.0.3 与 main 分支现状,并搜索已有 Issues/PR/Discussions 避免重复。
07、优化
基于第 06 章的两条证据,V2 做三处修改。
第一处,退避改为线性递增,并加随机抖动:
private void sleepWithJitter(int attempt) {
long base = backoff.toMillis() * attempt;
long jitter = ThreadLocalRandom.current().nextLong(0, Math.max(1, backoff.toMillis()));
try {
Thread.sleep(base + jitter);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
改它的原因是避免重试同步脉冲。新行为是每轮等待时间递增且不完全可预测,验证方式是连续制造 5 次失败,观察日志时间戳间隔是否递增。
第二处,把非可重试异常原样抛出,不再包装成 ModelCallException 的 retryable 分支,让上层能区分「不可用的模型」和「写错的参数」。改它的原因是错误语义要在类型上体现,而不是只体现在日志文字里。
第三处,网关增加一份「最近一次成功模型」的记录,供 Bridge 在会话里回传。改它是因为数字人的回复风格会跟着模型变,运维排查时需要知道这句话到底是谁生成的。
这三处都基于前文证据,没有引入新依赖,也没有声称任何未实测的性能数据。验证方法仍然是同一条:主动制造主模型失败,观察日志链,确认结果仍然返回。
08、演进
[同一输入]
|
+--[V1] 业务代码构造具体模型 / 代价:模型名写死,主模型抖动即会话中断
|
+--[V2] registry 解析 + 网关容错 / 代价:多一层间接,需接受回退带来的语义漂移
|
[Trade-off]
得到:模型可配置替换、故障可兜底、业务代码零 provider 依赖
失去:单次调用的确定性(回复可能来自备用模型)
适用边界:幂等读类调用、跨模型风格差异可接受、对重试成本不敏感
正确性上,V2 在「最终有结果」这个维度明显更强,但在「结果一定来自主模型」这个维度更弱——这是一个主动放弃的确定性。
稳定性上,V2 抵御的是瞬时故障,不抵御主模型连续不可用超过重试窗口的情况,那种情况需要更长的熔断窗口,本篇没做。
复杂度上,多了一个网关、一个分类器和一份退避策略,代码量不大,但心智负担上升了——排查时你必须先问「这次是主模型还是备用模型答的」。
成本上,重试意味着同一请求可能计费两次,这是真金白银的取舍,不能装作看不见。
适用范围是模型调用这一类外部依赖;遗留问题是熔断、并发隔离和按 provider 的独立限流都还没做。
09、洞见
9.1 反直觉判断:容错的入口在解析层,不在 Agent 层
很多人第一反应是在 Agent 外面包一层 try/catch。但 Agent 是有状态的,一次失败往往意味着它的内部状态已经推进了一半,重试同一个 Agent 等于让它带着半截状态再走一遍。而 ModelRegistry 是无状态的、纯粹的字符串到实例的映射,容错挂在这一层,天然是干净的。这也是为什么这一式要先把模型从代码里搬到注册表,再谈容错。顺序反了,后面每一步都会拧巴。
9.2 反直觉判断:重试次数不是可靠性参数,是故障放大器
直觉上「多试几次更可靠」。但如果失败原因是上游过载,那么每一次重试都是在给已经喘不过气的上游再压一块石头。重试真正的前提是「这次失败是独立事件」,而上游过载导致的失败是强相关的。所以重试次数应该由「失败的相关性」决定,而不是由「我们有多怕失败」决定。对限流类失败,正确的做法是退避加长,而不是次数加多。
9.3 回退的代价是语义漂移,不是延迟
大家评估回退时习惯算延迟:主模型 3 秒、备用模型 1.5 秒,回退后更快。但真正的成本在输出上。不同模型的回答长度、语气、是否倾向调用工具都不一样,一个数字人今天说三句话,明天说一句半,用户感知到的是「它变了」。所以回退链上的模型,最好在人格化 prompt 上做过对齐测试,而不是抓到什么用什么。
9.4 什么时候不该用本章能力?
四种情况建议不要上重试加回退。一是需要严格结构化输出、下游有 schema 校验的场景,回退后的输出格式可能直接不过校验。二是成本极度敏感、每次调用都要计费的场景,重试等于双倍账单。三是本地 Ollama 这类 provider,它的失败通常是「进程没起」,重试和回退都救不了。四是单模型内网部署、SLA 有明确保障且失败即告警即人工介入的场景,加容错反而掩盖了真问题。
10、系统落地
原来有什么?第4式留下的数字人 Demo,能跑通页面、房间、Bridge 和 Agent,但模型是写死的、唯一的、没有兜底的。
本篇新增了什么?一层模型网关、一份异常分类策略、一套 provider:model 的配置化解析,以及一条可主动复现的主模型失败链路。
现在能做什么?可以在不改一行业务代码的前提下把主模型从 DashScope 换成 OpenAI;可以让主模型抖动时自动落到备用模型;可以在日志里看到每一次重试和每一次回退。
还缺什么?缺熔断(连续失败后的快速失败窗口)、缺按 provider 的独立并发控制、缺成本统计(重试到底多花了多少钱)、缺回退模型的输出对齐测试。
下一步如何演进?模型层稳定之后,真正会暴露问题的就不是「换模型」了,而是「模型能干什么」——也就是工具调用。工具一旦进来,模型回退的难度会陡增,因为不同模型的 function calling 格式和行为差异远大于纯文本。这一点在后续章节会正面遇到。
开源参与点:本章未发现明确上游问题。唯一可记录的候选是文档层面的补充空间——provider:model 的命名约束、各 provider 失败特征的差异说明,是否需要官方文档补齐。这属于候选,不作为真实 Issue/PR 提出。
底座已成形
模型可替换
下一步看工具
别急着铺开
11、小结
Q1 -> 模型容错写在哪一层?写在模型解析层,由网关统一包裹,业务与 Bridge 不感知
Q2 -> 重试和回退有什么区别?重试治瞬时抖动且要求幂等,回退治通道不可用,两者必须分类
Q3 -> 选错层会怎样?参数错误被放大成漫长的失败,Agent 半截状态被反复推进
状态 -> chapter/05-model-registry 上,数字人具备主备模型与超时重试回退能力
这一式的交付物,是一个可以主动触发失败、并且能观察到完整重试与回退日志的模型层。它不提升数字人的「聪明程度」,它提升的是「不哑巴」的概率。
一问定配置
二问定分类
三问定回退
一役可交差
12、作业
12.1 理解题:Model、ModelProvider、ModelRegistry 三者的职责边界分别是什么?
参考答案:Model 是调用接口,定义「怎么用」;ModelProvider 是 SPI,定义「某一类 provider 由谁接入」,通过 Java ServiceLoader 机制被发现;ModelRegistry 是解析器,把 provider:model 字符串映射成 Model 实例。建议打开 v2.0.3 的 agentscope-core/src/main/java/io/agentscope/core/model/ModelRegistry.java,看它如何切分字符串、如何在未命中 provider 时返回错误,再对照 docs/v2/zh/docs/building-blocks/model.md 看官方推荐用法,不要只背本段结论。
12.2 实战题:在不改业务代码的前提下,让主模型必然失败一次,并让备用模型接管。
参考答案:只改配置文件中的 primary 值,例如把 endpoint 指向一个不存在的主机,或把 API Key 改成无效值,重启后在 Web 运行页发一句话。验收标准是三件事同时出现:日志里有重试记录、有回退记录、接口仍然返回了正常结构的结果。如果只出现回退没有重试,说明这次失败被分类成了不可重试,需要按第 06 章的方法调整触发方式。
12.3 排障题:配置了 max-attempts: 3,但日志里只有一次调用,直接回退。请给出排查顺序。
参考答案:先看失败日志里写的是 retryable 还是 non-retryable,这决定了是不是分类问题;如果是分类问题,检查异常是否被判为不可重试(如 400/401/403/404);如果不是分类问题,再看配置是否真的绑定到了网关实例(Spring 属性前缀、类型转换是否失败、是否用了默认值);最后看超时是否短于首次调用耗时,导致每轮都在 future.get 上抛出 TimeoutException 而被当成同一种失败统计。排查顺序的原则是:先分类,再绑定,最后才是时长。
12.4 架构判断题:把重试和回退直接写在 Realtime Bridge 里,可行吗?
参考答案:技术上可行,架构上不建议。Bridge 的定位是最薄的一层,只做媒体与消息适配;一旦把容错写进去,它就要同时理解模型异常、重试语义和 provider 差异,职责立刻膨胀。更麻烦的是,容错逻辑一旦绑在 Bridge 上,其他调用入口(比如后续的管理端试跑、批处理、测试)要么复制一份,要么绕开 Bridge 直连模型,形成两套模型访问路径。正确做法是把容错下沉到模型网关,让所有入口共用同一条路径。
读文要看源
动手造失败
排障按证据
架构问取舍
13、思考
这一式的核心冲突,其实不是「模型会不会挂」,而是「模型挂了这件事,应该由谁负责」。
写业务代码的人,天然认为这是基础设施的事;写基础设施的人,天然认为这是业务策略的事。最后的结果往往是没人写,直到演示当天出事。
我的判断是:模型容错属于模型层,不属于业务层,也不属于 Agent 层。它必须有一个唯一的、无状态的入口。这个入口一旦立起来,后面所有的模型能力——工具调用、结构化输出、多模态——都可以挂在同一条路径上增量演进,而不是每加一个能力就重写一次异常处理。
还有一个更隐蔽的判断:可靠性是一个需要被主动制造出来的东西,而不是被期望出来的。第 06 章那两条证据链,价值不在于修好了什么,而在于它们证明了「失败是可以被触发的」。一个不能主动触发的失败路径,等于没有。
「会用框架」和「能参与框架演进」的差别,往往就在这一步:前者等着故障发生,后者先把故障造出来。
一念辨主备
一证定根因
一改知取舍
一役见真章

浙公网安备 33010602011771号