在一条由多个 LLM 调用组成的计算链上,第一次生成的结果可能有问题,而问题往往要到下游才能被发现。发现错误之后,可以重新调用模型;但如果每次都从头运行整条链,已经确认过的中间结果也会被反复计算。

重试、自我检查和缓存都能处理其中一部分情况。一个不同的方向是:让每个计算节点保有当前候选结果及其验证状态;只有收到足以改变判断的反馈时才继续推理,满足接受条件后进入静默状态,后续发现反例时再重新激活。

我暂时把这种模型称作 Self-Stabilizing LLM Computation Graph(自稳定 LLM 计算图)。名称强调局部计算通过反馈逐渐稳定的过程,并不意味着系统已经具备分布式算法中严格的 self-stabilization 性质。

从一次调用到一个有状态的节点

假设应用把一次大模型调用抽象成 LLMCall,并把若干调用之间的数据依赖编译成 plan。编译阶段决定节点之间怎样传值,运行阶段则决定节点何时生成、何时等待、何时可以直接沿用已有输出。

一次生成仍然可以采用普通的 Go 风格接口:

func LLMCall(
    spec Spec,
    input Input,
    previous any,
    feedback []Feedback,
) (candidate any, err error)

第一次调用时 previous 为空;之后将候选结果与已有反馈带回模型,使它有机会沿着上一轮结果继续修改,而不只是重新采样。

但调用本身没有定义「修改是否更好」。LLM 可能修复一处问题,也可能引入新问题。因此模型只负责提出候选值;是否接受这个值、是否再执行一轮,由模型外的规则、测试、工具结果或其他验证者决定。

运行时至少需要保存这些信息:

type NodeState struct {
    Candidate           any
    Revision            uint64
    ContextKey          string
    DependencyRevisions map[string]uint64
    Checks              map[string]Verdict // 候选版本、验证器版本、用例 ID
    Status              Status // active, stable, invalid
}

ContextKey 标识本次求解所依赖的节点定义、规范化输入和必要的环境快照;DependencyRevisions 记录候选值建立时所见的上游版本。Checks 保存针对当前候选版本的验证结果,键至少能够区分验证规则与测试用例。节点定义和节点实例也必须分开:使用相同 prompt 处理两份不同输入,不应让它们共享同一个已确认的答案。

这里的 Status 不是由模型自己宣布的。它是 runtime 根据候选版本、依赖关系和验证证据维护的状态。

生成、确认与 PASS 是三件不同的事

可以把一个节点的运行拆成两段:先通过反馈寻找更合适的候选值,再判断当前候选值是否已经满足接受条件。

候选 v1 ──验证失败──> NACK(反例) ──> 生成候选 v2
                                            │
                                         验证通过
                                            │
                              收集针对 v2 的有效确认
                                            │
                                    达到接受条件
                                            ▼
                                       stable / PASS
                                            │
                            新反例或依赖变化:invalid
                                            │
                                      重新进入生成

最初的设想可以用计数器累计正反馈,达到阈值以后直接 PASS。计数器仍然有用,但必须说明它在数什么。对同一个候选重复运行完全相同的检查十次,不会自然产生十份新的正确性证据。更合理的做法是按验证用例或可区分的证据来源去重,并由验证策略规定哪些检查必须通过、哪些证据可以累计。

候选值一旦改变,针对旧版本的确认不能直接转移到新版本。反过来,针对旧版本发现的失败案例可以成为新版本的回归测试,但仍需要实际重新检查,不能直接判定新版本也失败。

不同测试用例也可能共享同一个盲点。因此验证策略除了去重,还应规定必要的覆盖范围;确认阈值是执行策略,不是正确性的概率估计。

这里还需要区分三个结果:

  • ACK:当前候选版本通过某项明确的语义检查。
  • NACK:当前版本未通过检查,并尽可能附带反例或失败约束。
  • UNKNOWN:无法确定是否满足条件,既不能当作成功,也不能伪装成失败证据。

验证器自身超时或崩溃,也应记录为 UNKNOWN 或单独的执行故障,不能把「验证没有完成」判成候选内容错误。

Go 接口中的 err 则用于报告调用超时、工具异常等执行故障。err == nil 只说明这次调用没有报告执行错误;prompt 没变也不能让确认计数增加。

当当前版本取得足够有效的确认,并且它的输入与依赖版本仍然匹配,节点才进入 stable。此后相同条件下收到请求时,它走 PASS 路径,直接传出已接受的值。PASS 不是永远跳过计算,而是附带有效性条件的静默执行路径。

这仍然不是普通的结果缓存。缓存关注如何复用已有值;这里还包含值如何被反复修正、为何获得接受,以及何时必须撤回。缓存可以是实现手段,却不能替代这些状态转换。

如果 plan 的执行器能区分活跃与稳定节点,就能把原本需要模型生成的路径局部替换成带条件的直接传值。但这只是针对某个输入及其依赖条件的特化,不代表模型已经把解决一类问题的方法编译成了通用的确定性程序。

异步电路的启发,以及类比的边界

这个结构可以借用异步电路的执行直觉:每个节点根据局部输入和信号推进,不依赖一轮覆盖全图的全局时钟。一个节点产生新候选后通知下游;没有需要处理的变化时,它不必不断调用模型。

但异步电路里的 request/acknowledge 握手,通常表达数据是否到达、操作阶段是否完成,而不是数据在应用语义上是否正确。本文的 ACK 是语义验证结果,并非电路握手信号本身。可以借用局部触发和确认消息的组织方式,不能把电路的确定性照搬到 LLM 输出上。

          候选值向前传递
输入 ──> A ──> B ──> C ──> 输出
                 ^     │
                 └─NACK┘
          反例发回候选产生者

这张图实际上叠加了两种关系。A → B → C 是数据依赖;C → B 是反馈消息,不应因为有回传信号,就直接把整个数据依赖图解释成一个循环依赖程序。第一版可以把数据依赖限制为有向无环图;如果允许真实的数据回路,还必须额外定义迭代轮次、终止条件和处理冲突版本的规则。

当 C 指出 B 某个候选版本有问题,B 应立即将这一版本标记为失效,并通知依赖它的下游不要再使用旧值。这一步不必等待 B 生成下一版。之后 B 可以根据反例局部修正;如果确认问题来自 A 的输入解释,再继续追溯上游。负反馈向可能负责修复的节点传播,结果撤回沿数据依赖向下游传播。 两者不是同一条边,也不是同一个动作。

保守实现可以让一次 NACK 导致整条链停止使用旧结果。若能维护依赖版本和来源信息,则可以只让受影响的子图重新计算。这与 self-adjusting computation 追踪依赖、在数据变化后更新受影响计算的机制有联系。额外的部分在于:这里的节点不只响应输入变化,还尝试通过验证反馈修改自己的候选结果。

无需中央 Agent 顺序指挥每次推理,不等于系统不需要 runtime。有人仍须负责消息投递、状态持久化、并发版本检查和资源预算;如果反馈丢失,还需要超时或补偿检查避免节点永久等待。否则「局部自治」很容易退化成竞态条件。

这与此前讨论的 事件驱动 Agent Runtime 是正交的关注点:事件驱动解决何时唤醒计算;这里进一步讨论被唤醒的节点持有什么候选值,以及它为何可以再次保持静默。

一个局部修正的例子

假设应用要生成 SQL,统计一个已解析为固定日期区间内「已付款且未退款」的订单数。三个节点分别负责解释请求(A)、生成只读 SQL(B),以及用独立准备的测试数据验证 SQL(C)。

A:日期区间、支付与退款条件
                │
                ▼
B:候选 SQL(可反复修正)
                │
                ▼
C:执行测试并检查预期结果

测试数据至少包含两笔已付款订单:一笔没有退款,一笔已经退款。正确查询应只统计前一笔。B 的第一版 SQL 漏掉退款过滤,C 因此得到 2,而测试预期是 1。

C 对 B@v1 发出 NACK,并附带这份反例。B 把上一版 SQL 和反例交给模型,产生 B@v2。C 重新验证,如果退款案例及其他必要用例都通过,就为第二版记录确认。A 的字段和时间区间解释未受质疑,不需要跟着重新生成。

B 达到约定的验证条件后可以进入 PASS。此后对同一筛选需求、相容 schema 和验证策略,执行器可以复用已经接受的 SQL,而无须再次生成它。

但这并不意味着可以直接复用查询得到的订单数。数据库内容改变以后,同一条 SQL 的运行结果也可能改变。SQL 文本的稳定性,与执行 SQL 得到的数据是否仍然有效,是两种不同的状态。 同样,「上周」这种相对时间表达需要先绑定到具体日期区间,否则看似相同的请求可能对应不同输入。

这个例子有可执行检查,因此容易观察节点是否修正了错误。即使所有测试都通过,结论也只覆盖这些测试和前提;它不能证明查询对所有数据库状态都正确。

静默不等于收敛,更不等于正确

如果没有新的反馈,所有节点都停止调用 LLM,计算图就可能进入静默状态。但它也可能只是安静地保留了一组错误答案。没有待处理事件是一种运行状态,不是正确性证明。

最明显的失败模式是验证者与生成者共享错误假设。如果同一个模型不断肯定自己的上一轮输出,计数器可能上涨,信息量却没有增加。对没有可靠 oracle 的开放任务,系统只能根据有限证据作出接受决定,不能把「暂未发现问题」说成「已经证明正确」。定期抽检可以增加发现反例的机会,但也重新引入计算成本,而且无法保证发现所有问题。

异步执行还要求验证消息绑定到具体版本。针对 B@v1 的旧 ACK 迟到时,不能用它确认 B@v2;旧 NACK 也不该无条件清零新版本的状态。若某个旧反例仍适用于新版本,应该作为测试重新执行。验证规则、prompt、模型、外部数据快照改变时,相关确认也可能需要重新评估。

反馈还可能形成振荡:一次修改修复约束 X,却破坏约束 Y;下一轮反过来。实际运行时必须有迭代预算、截止条件和无法接受时的退出路径。保留历史最佳候选可帮助回退,但前提是存在足够可靠的比较标准。否则,「历史最佳」也只是另一个未经证明的判断。

对于有副作用的节点,撤回候选更不能理解为自动撤销现实中的动作。已经发出的邮件、已提交的数据库写入,不会因为状态变成 invalid 就消失。探索阶段的候选生成和最终被授权的外部效果,需要不同的执行边界。

严格意义上的 self-stabilization 要求系统能够从任意初始状态最终进入合法状态,并保持合法状态。这里没有通用的合法性判据,也没有收敛证明。因此这个名称目前表达的是设计方向;局部获得确认、整图没有活跃计算,以及数学意义上的自稳定,必须分开讨论。

怎样判断它是否比普通迭代更有价值

最小实验可以从固定任务、可执行 oracle 和少量节点开始。比较单次生成、带相同反馈机制的整图迭代、以及本文的局部状态与失效传播;再加一组普通结果缓存作为对照,以排除收益仅来自简单复用的可能。

比较时应尽量使用相同的模型、验证器、测试用例与预算上限。除了最终正确率,还要记录达到接受条件或预算耗尽所需的模型调用、token、延迟、验证开销,以及多少错误候选被标记为 stable。可以主动注入错误、乱序 ACK/NACK、验证器误判和上游版本变化,检查系统是否错误复用旧结果,或重算了本来无须改变的节点。

如果收益仅仅是减少生成次数,却引入更多验证费用或更高的错误接受率,这个机制就不一定有价值。相反,如果局部反馈能在相同验证标准下减少重复推理,又能准确撤回受影响的结果,才说明「稳定状态」值得成为计算图的一等公民。

最需要先明确的不是确认阈值取 3 还是 10,而是:什么证据能让一个候选版本获得接受,以及一条新证据应当撤销哪些既有接受决定。 这两个问题解决以后,PASS 才能成为有定义的执行语义,而不只是一次乐观的缓存命中。