1. 一次漏调引发的思考:AI 工作流为什么总在关键时刻掉链子
前阵子我在用 MiMo V2.6 搭一套自动化内容处理流程,遇到一个特别典型的问题:模型明明在系统提示里被明确告知“每次输出前必须调用format_check这个 skill”,结果十次里有三次它直接跳过了这一步,把没经过格式校验的内容吐了出来。更离谱的是,这三次漏调并不是随机分布的——它们集中出现在输入内容特别长、或者用户指令里带了“快点”“简单说”这类催促词的时候。
这个现象让我意识到一件事:AI 工作流的可靠性问题,本质上不是模型能力问题,而是执行一致性问题。MiMo V2.6 本身在中文理解、长上下文处理、工具调用上的表现都相当能打,但只要你把多个 skill、多个 workflow 节点串起来,漏调、错调、顺序颠倒这些问题就会像幽灵一样冒出来。你测试的时候跑十遍都对,上线之后用户随便换个说法,流程就崩了。
我后来花了两周时间,把手上三套基于 MiMo V2.6 的工作流全部做了加固,总结出三道“护栏”。这三道护栏不是什么高深技术,但每一道都对应一类具体的失败模式,而且都能在 CI 里做自动化验证。下面我把整套思路拆开讲,从设计逻辑到实操配置,再到踩过的坑,尽量说透。
提示:本文讨论的“skill”指的是 AI 工作流中可被模型调用的原子能力单元,可以是函数、API、脚本或子流程;“workflow”指的是由多个 skill 按特定顺序编排而成的完整执行链路。这两个概念在不同平台叫法不同,但本质一样。
2. 第一道护栏:把 skill 调用从“建议”变成“契约”
2.1 为什么模型会漏调 skill
先说清楚漏调的根因,不然后面的护栏就是瞎搭。MiMo V2.6 这类模型在决定是否调用某个 skill 时,实际上在做一次隐式的“收益判断”:调用这个 skill 对当前任务有没有明显帮助?如果模型觉得“我直接生成答案也能满足用户”,它就会倾向于跳过 skill 调用,因为这样响应更快、token 消耗更少。
这个判断在单轮简单任务里没问题,但在多步工作流里就是灾难。比如你的 workflow 设计是“先调extract_keywords,再调search_knowledge,最后调format_check”,模型可能在第一步就觉得“关键词我直接能提取”,于是跳过第一个 skill,导致后续search_knowledge拿不到结构化输入,整个链路错位。
我实测下来,漏调高发的三种场景:
- 输入过长:上下文超过一定长度后,模型对系统提示中“必须调用”的注意力会被稀释。
- 用户催促:指令里出现“快”“简单”“直接说”等词时,模型会优先满足“快”这个显性需求。
- skill 描述模糊:如果 skill 的 description 写得像“可选辅助工具”,模型就会真的把它当可选。
2.2 契约式 skill 定义的写法
第一道护栏的核心思路是:不要让模型“判断要不要调”,而是让它“判断调哪个”。具体做法是把 skill 调用从自然语言建议改成结构化契约。
我用的写法是在系统提示里加一段强制声明,格式如下:
[EXECUTION_CONTRACT] 本工作流包含以下强制步骤,每一步必须在输出最终答案前完成: STEP_1: 调用 extract_keywords(input) -> 获得 keywords 列表 STEP_2: 调用 search_knowledge(keywords) -> 获得 reference 列表 STEP_3: 调用 format_check(draft) -> 获得校验结果 违反契约的输出将被视为无效。 [/EXECUTION_CONTRACT]关键点在于:用 STEP 编号 + 箭头 + 明确输入输出,而不是“你可以调用”“建议调用”。MiMo V2.6 对结构化标记的遵循度明显高于自然语言描述,我实测把这段加进去之后,漏调率从 30% 降到了 8% 左右。
但 8% 还是不够。继续加固的做法是在每个 skill 的返回值里加一个next_required字段:
{ "skill": "extract_keywords", "result": ["AI工作流", "skill调用"], "next_required": "search_knowledge", "contract_step": "1/3" }这样模型在拿到返回值时,会被再次提醒“下一步必须调什么”。这个机制相当于在链路中间加了接力棒,而不是只在起点说一次。
2.3 在 CI 里验证契约遵循度
光靠提示词不够,必须上自动化验证。我在 GitHub CI 里加了一个 job,专门跑契约遵循度测试:
name: contract-compliance on: [push] jobs: test-contract: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run contract tests run: | python tests/contract_test.py --model mimo-v2.6 --cases 50contract_test.py的逻辑是:准备 50 条测试输入,覆盖长文本、催促指令、模糊指令等场景,每条都跑一遍完整 workflow,然后检查执行日志里 STEP_1/2/3 是否都出现了。任何一条漏调就 fail。
我一开始只准备了 10 条用例,结果上线后还是出问题。后来把用例扩到 50 条,并且专门加了“用户说‘快点’”“用户说‘不用那么麻烦’”这类对抗性输入,才真正把漏调率压到 1% 以下。
注意:契约测试的用例不要只写“正常输入”,一定要写“诱导模型偷懒的输入”。这是我在实际项目里踩过的最大的坑——测试全绿不代表线上稳。
3. 第二道护栏:用状态机管住 workflow 的执行顺序
3.1 顺序错乱比漏调更隐蔽
漏调至少你能从日志里看出来“少了一步”,但顺序错乱往往更隐蔽。我遇到过一种情况:format_check和search_knowledge都被调用了,但顺序反了——模型先做了格式校验,再去检索知识,导致校验通过的内容里其实缺少关键引用。
这种问题在单次测试里很难发现,因为两个 skill 都执行了,输出看起来也“像那么回事”。只有当你对比预期输出和实际输出时,才会发现引用缺失。
根因在于:MiMo V2.6 在并行调用多个 skill 时,会根据自己的“效率判断”决定先调哪个。如果你的 workflow 对顺序有强依赖,就必须显式声明依赖关系。
3.2 状态机式 workflow 定义
第二道护栏的做法是把 workflow 定义成一个显式状态机,每个状态只允许一个 skill,状态转移条件写死。我用的是类似下面的结构:
{ "workflow_id": "content_pipeline", "initial_state": "S1", "states": { "S1": { "skill": "extract_keywords", "on_success": "S2", "on_failure": "S1_RETRY" }, "S2": { "skill": "search_knowledge", "on_success": "S3", "on_failure": "S2_RETRY" }, "S3": { "skill": "format_check", "on_success": "DONE", "on_failure": "S3_RETRY" } } }然后在系统提示里告诉模型:你当前处于状态 S1,只能调用 S1 对应的 skill,调用完成后根据返回值决定下一个状态。这样模型就没有“自由发挥”的空间了。
实测下来,状态机式定义把顺序错乱率从 15% 降到了接近 0。代价是灵活性下降——如果某个任务确实需要跳过某一步,你得额外定义一条状态转移路径,不能靠模型临场判断。
3.3 状态转移日志与回放
状态机还有一个好处:每一步的状态转移都可以打日志,出问题可以回放。我在每个 skill 的 wrapper 里加了统一日志:
def log_transition(workflow_id, from_state, to_state, skill, result): entry = { "ts": time.time(), "workflow": workflow_id, "from": from_state, "to": to_state, "skill": skill, "result_hash": hashlib.md5(str(result).encode()).hexdigest() } with open(f"logs/{workflow_id}.jsonl", "a") as f: f.write(json.dumps(entry) + "\n")有了这个日志,任何一次执行都可以完整回放:从哪个状态开始、调了哪个 skill、返回了什么、跳到哪个状态。排查问题时不用再靠猜。
实操心得:日志里的
result_hash很有用。当你想对比两次执行是否走了相同路径时,直接比 hash 就行,不用把完整结果都存下来。
4. 第三道护栏:让 CI 成为工作流的“守门人”
4.1 为什么 CI 是最后一道防线
前两道护栏都是在运行时约束模型行为,但运行时约束有个天然缺陷:你无法覆盖所有可能的输入。用户可能用你完全没想到的方式说话,模型可能在你没测过的场景下做出意外判断。
所以第三道护栏必须放在 CI 里,用自动化测试做回归。核心思路是:把每一次线上出问题的 case 都变成 CI 里的一个测试用例,这样同一个问题永远不会出第二次。
我在 GitHub CI 里维护了一个regression_cases/目录,每个文件是一个 JSON,记录输入、预期执行路径、预期输出关键字段:
{ "case_id": "reg_017", "input": "帮我快速处理一下这段文字,不用太复杂", "expected_path": ["S1", "S2", "S3"], "expected_output_contains": ["format_check_passed"], "added_reason": "用户催促导致漏调 format_check,2024-11 线上事故" }每次 push 都会跑全部 regression cases,任何一条路径不符就 fail。这个机制看起来笨,但极其有效。我维护了大概 80 条 case 之后,线上工作流事故率下降了 90% 以上。
4.2 CI 配置的完整写法
下面是我实际在用的 GitHub CI 配置,包含契约测试、状态机测试、回归测试三层:
name: workflow-guardrails on: push: branches: [main] pull_request: jobs: contract: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.11' - name: Install deps run: pip install -r requirements.txt - name: Contract compliance run: pytest tests/test_contract.py -v env: MIMO_API_KEY: ${{ secrets.MIMO_API_KEY }} state-machine: runs-on: ubuntu-latest needs: contract steps: - uses: actions/checkout@v4 - name: State transition tests run: pytest tests/test_state_machine.py -v regression: runs-on: ubuntu-latest needs: state-machine steps: - uses: actions/checkout@v4 - name: Regression cases run: python tests/run_regression.py --cases regression_cases/三个 job 串行执行,任何一层挂了后面的就不跑,节省资源。MIMO_API_KEY放在 GitHub Secrets 里,不要硬编码。
4.3 回归用例的维护策略
回归用例不是越多越好,关键是每条都对应一个真实问题。我的维护策略是:
- 线上每出一次问题,当天就补一条 case,并在
added_reason里写清楚事故背景。 - 每季度清理一次,把已经被其他 case 覆盖的冗余用例删掉。
- 用例按“失败模式”分组,比如
leak/、order/、format/,方便定位。
这样维护下来,80 条 case 覆盖了大概 95% 的历史问题类型,性价比很高。
5. 三道护栏的协同与常见问题排查
5.1 三道护栏各自管什么
把三道护栏放在一起看,它们的分工其实很清晰:
| 护栏 | 解决的问题 | 生效时机 | 主要手段 |
|---|---|---|---|
| 第一道:契约式 skill 定义 | 漏调 | 运行时 | 结构化契约 + next_required |
| 第二道:状态机 workflow | 顺序错乱 | 运行时 | 显式状态转移 |
| 第三道:CI 回归 | 未知场景 | 提交时 | 自动化测试 |
三道护栏不是替代关系,而是叠加关系。只做第一道,顺序问题管不住;只做第二道,未知输入管不住;只做第三道,反馈太慢。三个一起上,才能把执行一致性做到可接受的水平。
5.2 常见问题速查表
下面是我在实际项目里遇到的高频问题,整理成速查表:
| 现象 | 可能原因 | 排查方法 | 解决 |
|---|---|---|---|
| skill 偶尔不调用 | 契约描述不够强制 | 看执行日志里 STEP 是否齐全 | 加 next_required 字段 |
| skill 调用顺序反了 | 未声明依赖 | 对比 expected_path | 改状态机定义 |
| 长输入下漏调率升高 | 上下文稀释注意力 | 按输入长度分组统计 | 在输入末尾重复契约 |
| 用户催促时漏调 | 模型优先满足显性需求 | 加对抗性测试用例 | 契约里声明“速度不影响步骤” |
| CI 测试全绿但线上出问题 | 用例覆盖不足 | 对比线上日志和用例 | 补 regression case |
| 状态机卡死 | on_failure 未定义 | 看日志最后状态 | 补 retry 状态 |
5.3 几个容易踩的坑
坑一:契约写得太长。我一开始把契约写得特别详细,结果模型反而记不住。后来精简到只保留 STEP 编号和 skill 名,遵循度反而上升。契约要短、要结构化、要重复。
坑二:状态机状态太多。状态超过 7 个之后,模型在状态转移上的错误率明显上升。如果 workflow 确实复杂,拆成多个子 workflow,每个子 workflow 控制在 5 个状态以内。
坑三:回归用例只测 happy path。我早期 80% 的用例都是正常输入,结果线上出的全是异常输入导致的问题。后来强制要求每个功能至少 3 条对抗性用例。
坑四:CI 跑得太慢。全量回归跑一次要 20 分钟,开发者就不愿意等。我的做法是把回归分成fast(10 条核心用例,2 分钟)和full(80 条,20 分钟),push 时跑 fast,merge 前跑 full。
提示:如果你用的是 GitLab CI,配置逻辑一样,只是语法换成
.gitlab-ci.yml。核心是三层 job 串行 + secrets 管理 + 分组用例。
6. 从 MiMo V2.6 到通用 AI 工作流的迁移经验
6.1 换模型时哪些护栏要调整
这套护栏不是 MiMo V2.6 专属的。我后来把它迁移到另外两个模型上,发现大部分逻辑通用,但有几处需要调整:
- 契约格式:不同模型对结构化标记的敏感度不同。MiMo V2.6 对
[EXECUTION_CONTRACT]这种方括号标记响应很好,但有的模型对 XML 标签更敏感。迁移时先做小样本测试,找到该模型最“听话”的格式。 - 状态机粒度:有的模型在状态转移上更强,可以支持更多状态;有的模型需要更粗的粒度。这个只能实测。
- 重试策略:不同模型的失败模式不同,重试次数和退避策略要重新调。
6.2 迁移时的最小验证集
每次换模型,我都会跑一个最小验证集,包含:
- 10 条正常输入,检查契约遵循度。
- 10 条对抗性输入(催促、模糊、超长),检查漏调率。
- 5 条顺序敏感输入,检查状态转移正确性。
- 5 条历史 regression case,检查是否回归。
这个验证集跑一遍大概 10 分钟,能快速判断新模型是否适合当前 workflow。如果不适合,要么调整护栏,要么换模型。
6.3 一个真实的迁移案例
我手上有一套内容处理 workflow,原本跑在 MiMo V2.6 上,后来因为业务需要迁移到另一个模型。迁移前我预估要改不少东西,实际跑下来发现:
- 契约格式从方括号改成 XML 标签后,遵循度从 92% 回到 95%。
- 状态机不需要改,因为状态转移逻辑是平台无关的。
- 回归用例里有 3 条 fail,都是因为新模型对某个 skill 的返回值解析方式不同,调整 wrapper 后通过。
整个过程花了大概半天,比预想的快很多。核心原因是三道护栏把“模型相关”和“模型无关”的部分隔离开了——状态机和 CI 是模型无关的,只有契约格式需要按模型调整。
7. 一些关于 AI 工作流一致性的个人体会
这套护栏我用了大概半年,最大的体会是:AI 工作流的可靠性不是靠“更好的提示词”解决的,而是靠工程手段解决的。提示词优化能帮你从 70% 提到 85%,但要从 85% 提到 99%,必须上契约、状态机和 CI。
另一个体会是:不要追求 100% 一致。模型本质上是概率系统,你不可能让它每次都做完全相同的事。目标应该是“把不一致控制在可接受范围内,并且不一致发生时能被快速发现和修复”。我的目标是漏调率低于 1%、顺序错乱率低于 0.1%,达到这个水平之后,业务侧基本感知不到问题。
最后分享一个小技巧:如果你刚开始搭 AI 工作流,不要一上来就搞三道护栏,先从第一道契约开始,跑一周看看漏调率。如果漏调率已经很低,第二道可以缓一缓;如果漏调率居高不下,再上状态机。护栏是渐进的,不是一次到位的。我自己也是从只有契约、到加状态机、再到上 CI,一步步迭代过来的。