提出在 CI 中检查 AI 起草段落是否有机器可读的依据,并限制模型可修改的文件范围。检查目标是让文档陈述可追溯,内容是否正确仍由人工判断。
设想有一个文档 PR,新增了一节重试说明。文字整洁,表格对齐,其中一行写着:“设置 --retry-limit to 5,限制重试次数。”可这个 flag 早在两个版本前就改名了,CI 却毫无察觉,因为流水线检查的是 Markdown 格式,而不是内容含义。
这只是一个示例,并非客户的真实经历。不过,这类问题很常见:生成内容的审查成本,最终由人逐句阅读来承担,而生成成本下降,并不会让审查成本随之降低。
过去几周,DEV 上的讨论在两种情绪之间摇摆:一边惊叹模型能生成什么,一边疲于审查生成结果。这两种反应都指向同一个缺失环节——从“模型写完了”到“人类批准了”之间,少了一道自动检查。
大多数“AI 文档”工作流都会栽在两类平常的问题上:要么允许模型修改它不该碰的文件,要么允许模型陈述无法追溯来源的事实。这两类问题都体现在 diff 中,而 diff 可以用门禁来约束。
我使用的这道门禁只问一个很具体的问题:每个生成的段落背后,是否都有机器可读的依据?它不问“内容是否正确”——这仍然需要人来判断。它只问“内容能否追溯来源”,而脚本不到一秒就能回答这个问题。
在模型接触文件之前,先把每次文档变更划分为三条通道。
提取(Extract)——从已有来源中提取事实:--help 输出、OpenAPI schema、配置参考、测试 fixture 中的退出码。这条通道由脚本负责。
起草(Draft)——用连贯的文字,按顺序解释提取出的事实,并从 fixture 中选取示例。模型可以负责这条通道,但只能在明确标记的区块内工作。
负责(Own)——由人作出的承诺:支持周期、安全立场、弃用日期、定价,以及任何读者可能据此要求你履约的内容。模型完全不得进入这条通道。
关键不在于这些名称,而在于第 1 条和第 3 条通道都有工具强制约束。这样,第 2 条通道就可以自由重新生成,审查者也不必重新核查整个文件。
草稿只能写在标记之间。草稿区块中的每条事实都必须带上类似 {{claim:cli.retry-limit}} 的锚点。门禁会对照 claims.json 解析锚点,并拒绝没有锚点的数字。
#!/usr/bin/env python3
"""anchor_gate.py - fail the build when drafted prose outruns its evidence.
Usage:
python anchor_gate.py docs/ claims.json --max-orphans 0.20
Exit codes: 0 = pass, 1 = gate failed, 2 = bad input.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
START = re.compile(r"<!--\s*draft:start\s+id=([\w.-]+)\s*-->")
END = re.compile(r"<!--\s*draft:end\s*-->")
ANCHOR = re.compile(r"\{\{claim:([\w.-]+)\}\}")
NUMBER = re.compile(r"\d[\d,_]*(?:\.\d+)?")
def draft_blocks(text: str) -> list[tuple[str, list[str]]]:
blocks, block_id, buf = [], None, []
for line in text.splitlines():
start = START.search(line)
if start:
block_id, buf = start.group(1), []
continue
if END.search(line) and block_id is not None:
blocks.append((block_id, buf))
block_id = None
continue
if block_id is not None:
buf.append(line)
return blocks
def paragraphs(lines: list[str]) -> list[str]:
out, buf = [], []
for line in lines + [""]:
if line.strip():
buf.append(line.strip())
elif buf:
out.append(" ".join(buf))
buf = []
return out
def main() -> int:
if len(sys.argv) < 3:
print(__doc__)
return 2
docs_dir, claims_path = Path(sys.argv[1]), Path(sys.argv[2])
claims = json.loads(claims_path.read_text(encoding="utf-8"))
budget = 0.20
if "--max-orphans" in sys.argv:
budget = float(sys.argv[sys.argv.index("--max-orphans") + 1])
total = orphans = 0
errors: list[str] = []
for md in sorted(docs_dir.rglob("*.md")):
for block_id, lines in draft_blocks(md.read_text(encoding="utf-8")):
for para in paragraphs(lines):
total += 1
ids = ANCHOR.findall(para)
unknown = sorted({i for i in ids if i not in claims})
if unknown:
errors.append(f"{md.name}:{block_id}: unknown claim(s) {unknown}")
if not ids:
orphans += 1
if NUMBER.search(para):
errors.append(
f"{md.name}:{block_id}: number without an anchor -> {para[:60]!r}"
)
rate = orphans / total if total else 0.0
print(f"draft paragraphs: {total} orphans: {orphans} rate: {rate:.2f}")
if rate > budget:
errors.append(f"orphan rate {rate:.2f} exceeds budget {budget:.2f}")
for err in errors:
print(f"FAIL {err}")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())
无锚点段落的比例上限,比那些硬性错误更重要。文章里总会有不包含事实的句子,比如过渡、铺垫或定义。把上限设为 20%,既能避免门禁沦为那种大家第二周就会关掉的 lint 检查,也仍然能抓住悄悄编造默认值的段落。
手工维护的 claims 文件,过时的速度与它要保护的文档完全一样。应该从代码已经信任的那些产物中生成它。
{
"cli.retry-limit": { "value": "3", "source": "app.py --help" },
"schema.timeout_ms": { "value": "1500", "source": "openapi.yaml#/components/schemas/Retry" }
}
通常,写一个二十行左右的提取器,处理 argparse 帮助文本和 schema 中的键,就足够起步了。如果某条事实无法提取,这本身就是一个信号:要么它属于负责通道,要么缺少权威来源,文档暂时就不该作出这项陈述。
把这份清单写进仓库中的 human_owned.txt,并让生成器拒绝修改这些章节。
最后一行正是大多数团队容易犯错的地方。模型可以为一个它从未见过的设计,编出貌似合理的理由。而貌似合理的理由,恰恰是最难审查出来的错误。
重新生成,才是这套工作流的核心:schema 变了,就重新起草,而不是手工修补。要做到这一点,草稿生成的成本就必须足够低,低到每个 PR 都能运行,而不是每月才跑一次。
MonkeyCode 是一个选项——该项目宣称开源,提供免费模型访问和免费服务器选项,因此,为每个 PR 生成草稿的费用低到没什么好争论的。这个账号的运营者称,免费额度为 1000 万 tokens;应把它当作一个需要根据项目当前条款核实的初始数字,而不是容量规划的依据。
披露:本文是作为 MonkeyCode 产品推广活动的一部分撰写的。
免费服务器选项也正好满足了整个流程的另一半需求:门禁和提取器需要一个运行检查的地方。调用模型、只在标记区块内写入内容,再运行 anchor_gate.py,这样的草稿生成任务负载很小,也很普通——这正是适合放在免费服务器上运行的工作,无须占用承载生产流量的集群。
与其相信一个整数额度,不如估算自己的实际消耗。token 数量因 tokenizer 而异,但对于英文文本加代码,可以用每个 token 约四个字符,作为粗略的规划依据:
def rough_tokens(text: str) -> int:
# planning heuristic only; measure with your model's tokenizer
return len(text) // 4
对五篇最长文档的 prompt 运行这个函数,再乘以每周重新生成的次数,就能得到一个有依据的估算值。如果免费额度不够,就把草稿生成限制在发生变更的页面上——提取器已经能告诉你哪些锚点发生了变化。
没人测试的门禁,最终会变成什么都放行的门禁。
正常区块——一个段落、一个有效锚点,没有未被锚点覆盖的数字。预期退出码为 0。
未知锚点——{{claim:cli.ghost-flag}} 不在 claims.json 中。预期退出码为 1,并输出区块 id。
无锚点数字——“重试次数默认为 5”,没有锚点。预期退出码为 1。
合理的无锚点段落——一句不包含事实的铺垫文字,将比例上限设为 0.0。预期退出码为 1,而且只在比例检查那一行报错,以此证明程序确实读取了这个上限。
确认它可靠后,再加第五个用例:用相同输入,将同一个区块重新生成两次,然后对输出做 diff。非确定性不是 bug,但如果一个区块每次运行都会换一种措辞,就会给 git 历史制造大量无意义的变更,久而久之,审查者也会学会忽略 diff。
锚点覆盖率衡量的是可追溯性,而不是真实性。错误的提取器会产出带着明确锚点的错误陈述,门禁仍会放行。
这套方法还假定,存在机器可读的权威来源。如果产品行为由一份 PDF、一个客服收件箱,或者某个人的记忆来定义,提取通道就是空的,这套工作流也就没有立足点。
如果文档主要是叙述性内容,比如架构决策、事故复盘,或价值在于作者判断的教程,就不要采用这套方法。如果已经有正式审查流程,能将每句话都落实到签字负责人,也不必再用;叠加锚点只会增加一套语法,重复劳动。
挑出 flag 和默认值最多的文档目录,写好标记注释,让门禁先以只报告、不拦截的模式运行一周。它输出的那个数字——无锚点段落占比——是我所知道的、脚本无需理解任何一句话就能计算出的最有用的文档指标。
如果你要尝试为每个 PR 生成草稿,先从少量页面开始,并把人工负责的内容清单列得比你认为必要的更长一些。
如需采取进一步行动,你可以考虑屏蔽此人和/或举报滥用行为。