这段判断该写死在代码里还是交给模型,我每次都在这里返工
数据截至 2026-07,各模型服务的接口能力与错误码口径以官方最新说明为准。
大多数人把这类返工归因成”模型不够聪明”,其实它是一个可以提前算出来的结构问题:这个判断的取值范围,你能不能在写代码的时候就把它列全。 能列全的,交给模型就是自找不稳定;列不全的,写死成规则就是自找漏判。你在这两边来回横跳,改一版好一版、过两周又坏,不是模型退化,是边界一开始就没划。
站内有两篇容易和这篇混在一起看:Agent 角色分工 讲的是一件事该拆给几个角色、各自管哪一段;结构化输出不稳怎么治 讲的是内容已经由模型定了、怎么把它可靠地变成数据。本篇只管前置的那一刀——这个判断到底该不该进模型,判据是什么、怎么验、划错了怎么收回来。
一、先分清:你纠结的是”判断”还是”表达”
排查从这里开始,因为一半的争论其实是问了错的问题。
把一次调用拆成两段看。第一段是判断:在若干条可能的路里选一条,或者给出一个会被下游拿去做分支的值。第二段是表达:把已经定下来的东西写成人话、写成代码、写成 JSON。这两段的失败长得很像,但根因完全不同。
判断出错的典型样子是:同样的输入,跑十次有两次走了另一条分支;或者出现了你的下游代码根本没准备处理的值,直接抛 KeyError。表达出错的样子是:分支选对了、内容也对,但外面裹了一层解释文字,解析器啃不动。
怎么快速验证是哪一段?把同一份输入重复跑若干次,只看那个关键字段落在哪个取值上。字段值飘、且飘出了你预期的集合,是判断层的问题;字段值稳定、但整段响应的格式时好时坏,是表达层的问题,那属于另一篇的范围。
分不清就往下走,你会花两天去调措辞和解析,而问题在于这个判断压根不该由模型来做。
二、可枚举性判据:三问定归属
我用三个问题来定归属,顺序不能换,前一个能定的就不问后面的。
第一问:取值能不能在写代码时列全?
这是主判据。判断的输出如果是一个有限集合,而且这个集合你现在就能一条一条写出来、写完之后不需要模型来兜底”其他”,那它就该是代码。文件后缀映射到语言类型、HTTP 状态码归类到重试或不重试、金额是否超过审批线、目录是否在允许改动的范围内——这些全都能枚举,全都不该问模型。
反过来,取值本质上是开放的,比如”这段用户反馈在抱怨什么”、“这两个函数是不是在做同一件事”、“这段报错和上一次是不是同一个根因”,你没法列全,那就交给模型,但要在它外面加一层把开放结果收敛回有限集合的映射。
注意一个常见的伪枚举:你能列出十条规则,但每条规则的触发条件本身是模糊的。这叫”可枚举的动作 + 不可枚举的条件”,正确做法是模型只做条件判断、输出枚举值,动作由代码执行。
第二问:判错了能不能自动发现、能不能回滚?
同样不可枚举的判断,后果不一样,归属也不一样。判错了下游立刻报错、或者有一层校验会拦住,那可以放心交给模型试。判错了会静默产生一条脏数据、或者直接把文件删了、把提交推上去了,那即使难枚举也要退回到”模型出建议、代码做决定、人确认执行”。
这一层的具体做法是权限收口和确认点设计,这里只用它做归属判据:不可逆的动作永远不由模型直接触发。
第三问:这条规则多久变一次?
有些判断能枚举,但集合一直在长——比如新接入的第三方服务名、新的业务分类。这时候容易得出一个错误结论:“既然老变,那就交给模型算了。“变更频繁不改变归属,它只改变取值表放在哪里。判断逻辑该在代码里,取值表该在代码之外的配置或规则文件里,加一个取值只改数据、不动分支,也就不必为此发一次版。
这一问要防的是另一种偷懒:为了省掉配置加载这几十行,干脆让模型每次现场判断”这个服务名属于哪一类”。省下的是一次性的工程量,换来的是每一次调用都要承担一次不确定性,而且新加的服务名到底有没有被认对,你在日志里看不出来。
三问走完,绝大多数纠结都能落地。剩下真正难定的那一小撮,是”取值开放 + 后果可逆 + 变更频繁”的组合,那才是模型的主场。
三、现象 → 成因判别表
线上出问题的时候,你手里通常只有现象。用这张表往回推。
| 现象 | 大概率成因 | 怎么验证 | 处置动作 |
|---|---|---|---|
| 同一输入多次运行走到不同分支 | 可枚举的判断被交给了模型 | 固定输入重复跑多次,统计关键字段的取值分布 | 把这个字段改成代码规则,模型只做前置的语义抽取 |
| 下游拿到没见过的取值直接抛异常 | 模型输出未被收敛回有限集合 | 在解析后加一层集合断言,看是否触发 | 加白名单映射,落到集合外一律走默认分支并记录 |
| 新增一类输入就得改代码发版 | 取值表被硬编码进了判断逻辑 | 数一下改一条规则要动几个文件 | 判断逻辑留代码,取值表外移到配置或规则文件 |
| 规则越加越多、互相打架 | 用规则去逼近本质模糊的条件 | 看是否出现”如果包含 A 但不包含 B 且长度大于 N”这类堆叠 | 把条件判断交给模型,只让它输出枚举值 |
| 每次修一个 case 就坏一个旧 case | 判断的边界从未被固化成用例 | 把最近几次修过的 case 重新跑一遍,看有几条已经退回错误结果 | 先补回归集,再改判断,见评测方法那篇 |
| 结果对但格式时好时坏 | 这是表达层问题,不是归属问题 | 只看关键字段取值是否稳定 | 走解析与约束那条线,别动归属 |
| 报 429 或超时后整条链路给了兜底答案 | 失败被当成了一种正常判断结果 | 断网或人为注入错误,看输出是否仍然”成功” | 错误必须独立于业务取值传播,不能混进枚举里 |
最后两行是高频误判。前者会让你误以为是判断不稳,其实归属没错;后者更危险——把网络失败包装成”模型判断为无需处理”,这条链路会安静地一直错下去,而且它在监控上看起来一切正常。
四、把判断从模型手里收回代码的四种做法
确认某个判断该归代码之后,别直接删掉调用改成 if-else,那样容易把模型顺手做的语义抽取一起丢了。按下面四种做法,从轻到重挑。
做法一:模型只出枚举值,动作全在代码。 让模型的输出退化成一个标签,代码拿标签查表决定做什么。这是最省事的改法,改动量小,收益立刻可见。落地时要在解析后立刻做集合校验:
from enum import Enum
class Intent(str, Enum):
REFUND = "refund"
SHIPPING = "shipping"
OTHER = "other"
def to_intent(raw: str) -> Intent:
key = (raw or "").strip().lower()
try:
return Intent(key)
except ValueError:
return Intent.OTHER # 落表外一律进兜底分支,并记录原值
关键在最后一行:集合外的值不是异常,是默认分支加一条日志。你要靠这条日志的量来决定枚举表是不是该扩。
做法二:前置硬闸门。 一部分判断根本不用进模型。路径是否在允许目录内、文件是否二进制、改动行数是否超过阈值,这些在调用之前就该拦掉。闸门写在代码里,模型看不见被拦掉的东西,也就没机会做错决定。这一层顺带省下大量无效调用。
做法三:规则先行、模型兜底。 顺序是规则命中就直接返回,没命中才走模型。它的好处是规则表越长,模型调用越少,且行为越来越确定。反过来的顺序(模型先跑、规则校验)看着差不多,实际每次都要付出一次调用,且规则只能否决不能加速。
做法四:模型出建议、代码做决定、人确认执行。 用在不可逆动作上。模型的输出被降级成一条建议记录,真正的执行由代码在收到明确确认后触发。切换的时候留一个开关,别把老路径删掉:
export AGENT_DECISION_MODE=rules # rules | model | shadow
shadow 那一档很有用:两条路都跑,只采用规则的结果,把模型的结果写进日志做对比。跑上一段时间,你就有了真实分歧率,而不是靠感觉争论。工具边界怎么切得更细,可以对照 Agent 工具设计。
五、什么时候别再折腾
划错边界的代价是持续的,所以要有明确的止损点。下面四条满足任意一条,就停手换路,别再调了。
规则数量超过你能一眼看完的量,且新增一条会改变旧条的行为。 这说明你在用离散规则逼近一个连续的判断。继续加规则的边际收益是负的——每加一条,回归集里就有旧 case 翻车。止损动作是把这段条件判断整体交给模型,只保留动作层的枚举。
同一个字段的措辞你改了三轮以上还在飘。 措辞是概率约束,改到第三轮还不稳,基本可以断定这个字段不该由模型定。止损动作是把它降级:让模型输出更粗的分类,细分支由代码根据其他确定性信号来分。
分歧率下不去,但每一次分歧你都说不清谁对。 这不是技术问题,是你没有判定标准。这时候继续调是浪费,先去把评判标准写成固定用例,做法见 Agent 评测方法。没有回归集的调优,本质是随机游走。
为了让模型判断准,你开始往上下文里塞越来越多的背景。 这是典型的越界信号:一个判断需要大量额外背景才做得对,说明所需信息在代码里是现成的、结构化的,你却绕道让模型从文字里重新推一遍。止损动作是把那份结构化数据直接喂给规则。
回滚点怎么留?改归属属于行为变更,一定要单独成一次提交,别和重构混在一起。出问题的时候你要能干净地退回去:
git log --oneline -- path/to/decision_layer.py
git revert <commit>
再补一句关于工具选择的现实:部分海外模型服务官方并未向中国大陆提供直连,市面上存在各种第三方中转,是否可用、是否合规需要你自己判断,这里不做背书也不给渠道。把关键判断押在一个你无法保证可达性的服务上,本身就是一个该止损的架构决定。
六、避坑清单
坑一:把”模型能做”当成”模型该做”。 会踩是因为验证时它确实做对了,你就顺手留下了。避法是加一问——这个判断如果错了,多久会被发现?发现不了的,一律往代码收。
坑二:枚举表和下游分支两处维护。 会踩是因为加取值的时候只改了一处,另一处默默走进了兜底。避法是让下游分支从同一个枚举定义里生成,或者写一条测试断言两边的键集合完全相等,加取值忘了同步会直接红。
坑三:把错误当成一种判断结果。 会踩是因为异常处理里图省事返回了一个合法的默认值,429、超时、解析失败全被抹平成”正常”。避法是错误单独一条通路,业务枚举里绝不允许出现表示失败的成员,重试策略参考 Agent 失败重试。
坑四:拿单次运行的结果确认边界。 会踩是因为改完随手跑一次通过了就收工,而判断层的问题本来就是概率性的。避法是固定输入重复跑并统计分布,只要出现两种以上取值就当没通过。
坑五:规则外移之后没人管配置的合法性。 会踩是因为规则文件被当成纯数据,谁都能改,改错了到运行时才炸。避法是启动时校验配置——键是否在枚举内、是否有重复、是否有孤儿分支,校验不过直接拒绝启动,别等到线上。
坑六:把判断和执行写在同一个函数里。 会踩是因为一开始只有两条分支,顺手就写在一起了。等你想加 shadow 对比、想换归属的时候,会发现根本切不开。避法是从第一天就让判断函数纯粹——只返回枚举值,不产生任何副作用,执行放在调用方。
坑七:以为归属划一次就定了。 会踩是因为业务在变,今天开放的取值半年后可能已经收敛。避法是把兜底分支的命中量当成指标看,它持续上升就该重新划边界了。
收束
这件事没有普适答案,但有普适顺序:先确认你纠结的是判断层还是表达层,再用可枚举性、可逆性、变更频率三问定归属,最后靠 shadow 模式和回归集验证你划得对不对。
提交前对着这五条自查一遍:
- 这个判断的取值我现在能不能一条不落地写出来?
- 落在集合之外的值,会走进哪条分支、有没有留下记录?
- 判错了多久会被发现,发现之后能不能一条命令回滚?
- 错误路径有没有混进业务枚举里?
- 同一份输入跑多次,关键字段的取值是不是只有一种?
五条都答得上来,这段边界就算划稳了。答不上来的那条,就是你下一次返工的位置。