Skip to content

没有确定性的软件工程

六十年的隐含假设

软件工程从诞生那天起就有一个假设:程序的行为是确定性的。

这个假设太基础了,很少有人把它当作假设来审视。测试方法论假设它:给定相同的输入,程序应该产生相同的输出,否则就是 bug。调试方法论假设它:bug 是可复现的。形式化验证假设它:程序的行为可以用数学证明来保障。版本控制假设它:如果行为变了,一定是因为代码变了。

即便引入了并发和分布式系统,这个假设也没有被打破。并发的不确定性是调度层面的,每个线程内部仍然确定。分布式系统的"不确定性"是信息延迟——数据库不会"创造"一条你从未写入的记录。

大模型打破的是语义层面的确定性。同一个 prompt 的两次调用可能返回不同的内容,而且两个不同的内容可能都是"正确"的。

概率性的四个层级

不确定性也分好几种。

Token 级。 自回归生成的固有属性:每一步是一次采样。设置 temperature=0 可以接近消除,但会失去生成多样性。

语义级。 即使在贪心解码下,不同的 prompt 措辞仍可能导致不同的输出语义。prompt 到语义的映射本身是多对多的。

模型级。 模型提供商可以不打招呼就更新权重、调整推理参数、修改安全过滤。你的代码一行没改,系统的表现可能已经变了。

系统级。 多个 LLM 调用串联时,每一步的概率性会叠加。单步可靠性 95%,五步串联降到 77%,十步降到 60%。这是数学规律决定的硬限制,"写更好的 prompt"绕不过去。当你看到一个 10 步的工作流时,首先应该问的不是每一步怎么优化,而是能不能减到 3 步。

哪些失效了,哪些更重要了

确定性假设打破之后,有些工程原则失效了,有些反而变得更重要。

失效的:assert f(x) == expected 这个模式——对 LLM 不存在唯一正确输出,测试的目标要从断言等价改成断言属性。"bug 是可复现的"——概率性系统中同样的输入可能无法复现同样的输出。"代码没变行为不该变"——LLM 是外部依赖,要保证系统正确就必须持续监控。

更重要的:类型系统是确定性的最后堡垒——输出的结构可以强制约束,即便内容仍然是概率的。契约式设计在边界处更关键,因为系统内部不可预测时,前置条件、后置条件、不变量就是仅剩的保障。可观测性必不可少——问题不一定可复现,必须在发生时就捕获足够的信息。防御式编程也一样:每次 LLM 调用的输出都要先验证,再交给下游,就像你不会把用户输入直接拼进 SQL 查询。

能力边界

知道大模型做不好什么,比知道它能做什么更重要——而且这些短板是机制决定的,不会因为模型升级就消失。

精确计算。 模型生成的是统计意义上"合理"的 token 序列。精确计算要求 100% 准确,差一点都不行。LLM 负责意图解析,确定性代码负责执行计算。

状态维护。 每一次调用都是独立的。上下文窗口的"记忆"本质上是把历史当作条件前缀——容量受限、没法挑着记住重要信息、没有副作用。状态管理必须显式、外部、确定。

一致性。 同一个问题问两次可能得到不同答案;逻辑等价的两种问法可能得到矛盾的答案;一次长输出的前半段和后半段可能自相矛盾。确定性系统自带一致性保证,概率性系统必须显式检查和强制约束。

忠实执行指令。 prompt 是条件。模型遵从指令是概率性的,否定指令尤其不可靠——"不要做 X"在 token 层面反而激活了 X 相关的概率,可能增加生成 X 的概率。指令越多越复杂,遵从率越低。

自我评估。 模型说"我很确定"只是一个高概率的续写。可靠的质量评估需要外部手段。

这些边界指向一个分工原则:LLM 负责理解意图和生成自然语言,确定性代码负责计算、状态和一致性。两者用类型化的契约接口连接。模糊的归 LLM,精确的归代码。