AI 生成的 API 文档常把 limit=0(无上限)、limit 缺省(默认20)、limit=null(报错)混为一谈,建议用 OpenAPI closed schema 显式定义三者差异,并在文档生成流程中强制校验。
典型的重生成失败是静默的。模型返回的 /list-invoices 页面带有一张参数表,表格看起来是完整的。但随后有客户端因为表格写了"0 表示无限制"而发送了 limit=0。
OpenAPI 文件从未说明这一点。limit 是可选的,服务端默认值为 20。发送 0 返回空页面,发送 JSON null 到 query string 会得到 400。三种状态,一个坍缩的句子。
这不是语气问题,是契约问题。模型擅长填充表格,但拙于保留"字段缺失"、"显式 null"和"数值零"之间的差异。
以这个查询结构为例:
parameters:
- name: limit
in: query
required: false
schema:
type: integer
minimum: 0
maximum: 100
default: 20
- name: cursor
in: query
required: false
schema:
type: string
nullable: true
- name: include_deleted
in: query
required: false
schema:
type: boolean
文档模型通常会生成这样的内容:
limit (integer, default 0) — 返回的发票数量。用 0 表示全部。cursor — 不透明的分页令牌。传 null 从头开始。include_deleted — 可选。省略或为 null 时默认为 false。
每一行都很流畅。每一行都以不同的方式出错。
limit 默认值是 20,不是 0。零是一个合法的值,但含义不同。
cursor 省略表示"第一页"。cursor=null 不是有文档记录的开始令牌。
include_deleted 在 schema 中没有默认值。将省略和 null 视为 false 是一条虚构的策略。
如果你只检查 hallucinated URL,这个页面就发出去了。损害体现在客户端 SDK 和工单里,而不是 Markdown linter 中。
把模型限定在 schema 之内。如果某个事实写在 OpenAPI 里,模型可以复述它。如果某个事实不在 OpenAPI 里,模型不应该编造替代品。
可起草的(schema 支撑的):
不可起草的(人拥有的):
划分是机械性的。名称和类型是结构性的。缺失的含义是行为性的。重生成前者不应改写后者。
这张表才是产物,而不是周围 prose。如果某一行无法从 spec 填充,格子就留空,直到有人在签名文件中写入。
保存为 fixtures/list-invoices.openapi.yaml。它故意做得很小。Gate 应该对含义失败,而不是对文件大小失败。
openapi: 3.0.3
info:
title: Invoices
version: 0.0.0
paths:
/v1/invoices:
get:
operationId: listInvoices
parameters:
- name: limit
in: query
required: false
schema:
type: integer
minimum: 0
maximum: 100
default: 20
- name: cursor
in: query
required: false
schema:
type: string
nullable: false
- name: include_deleted
in: query
required: false
schema:
type: boolean
responses:
"200":
description: A page of invoices
人类拥有的 notes 放在 spec 旁边,而不是放在生成的页面内。例如:docs/owned/listInvoices.states.md。
# listInvoices — three-state notes
# owner: api-docs
# signed: true
## limit
- omit: server applies 20
- 0: empty page, not "unlimited"
- null: not a legal query value (400)
## cursor
- omit: first page
- empty string: 400
- null: 400
- opaque token: server-defined; do not document internals
## include_deleted
- omit: unspecified; do not claim a default
- true / false: filter as named
- null: 400
生成的 Markdown 可以引用这些 notes,但不得将它们改写成更友好的默认值。
下面的检查器是一个自包含示例。对 fixture 运行它。这是一个契约 linter,不是生产级文档平台。
#!/usr/bin/env python3
"""Fail docs regen when a model collapses omit / null / zero."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
try:
import yaml
except ImportError:
yaml = None
INVENTED_DEFAULT = re.compile(
r"defaults?\s+to\s+[`'"]?(all|unlimited|none|null|0|false|true)",
re.I,
)
EQUATES_NULL_OMIT = re.compile(r"null\s+(means|is)\s+(omitted|absent|missing)", re.I)
ZERO_MEANS_ALL = re.compile(r"\b0\b.{0,40}(unlimited|all records|no cap)", re.I)
def load_spec(path: Path) -> dict:
text = path.read_text(encoding="utf-8")
if path.suffix in {".yaml", ".yml"}:
if yaml is None:
raise SystemExit("pip install pyyaml")
return yaml.safe_load(text)
return json.loads(text)
def iter_params(spec: dict):
for path, item in (spec.get("paths") or {}).items():
for method, op in item.items():
if not isinstance(op, dict):
continue
op_id = op.get("operationId") or f"{method.upper()} {path}"
for param in op.get("parameters") or []:
schema = param.get("schema") or {}
yield {
"op": op_id,
"name": param.get("name"),
"required": bool(param.get("required")),
"type": schema.get("type"),
"default": schema.get("default", _Missing),
"nullable": schema.get("nullable", False),
"minimum": schema.get("minimum", _Missing),
}
class _Missing:
pass
def classify(param: dict) -> dict:
flags = []
if param["default"] is _Missing and not param["required"]:
flags.append("no_default_do_not_invent")
if param["default"] is not _Missing:
flags.append("default_is_schema_owned")
if param["nullable"]:
flags.append("null_is_not_omit")
else:
flags.append("null_is_invalid_unless_spec_says")
if param["type"] == "integer" and param["minimum"] == 0:
flags.append("zero_is_a_value")
return {**param, "flags": flags}
def scan_markdown(md: str, classified: list[dict]) -> list[str]:
errors = []
if INVENTED_DEFAULT.search(md):
errors.append("invented_default_phrase")
if EQUATES_NULL_OMIT.search(md):
errors.append("null_equated_to_omit")
if ZERO_MEANS_ALL.search(md):
errors.append("zero_collapsed_to_unlimited")
for row in classified:
if "no_default_do_not_invent" in row["flags"]:
pat = re.compile(
rf"{re.escape(row['name'])}.{{0,80}}defaults?\s+to",
re.I | re.S,
)
if pat.search(md):
errors.append(f"invented_default:{row['op']}:{row['name']}")
if "zero_is_a_value" in row["flags"] and row["default"] != 0:
pat = re.compile(
rf"{re.escape(row['name'])}.{{0,80}}default\s+[`']?0",
re.I | re.S,
)
if pat.search(md):
errors.append(f"wrong_default_zero:{row['op']}:{row['name']}")
return errors
def main(argv: list[str]) -> int:
if len(argv) != 3:
print("usage: three_state_gate.py <openapi> <generated.md>", file=sys.stderr)
return 2
spec = load_spec(Path(argv[1]))
md = Path(argv[2]).read_text(encoding="utf-8")
classified = [classify(p) for p in iter_params(spec)]
errors = scan_markdown(md, classified)
print(json.dumps({"params": classified, "errors": errors}, indent=2, default=str))
return 1 if errors else 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
一个有问题的页面长这样。保存为 fixtures/list-invoices.bad.md:
## Query parameters
- `limit` (integer, default `0`) — use `0` for unlimited.
- `cursor` — pass `null` to start. Null means omitted.
- `include_deleted` defaults to false.
python3 three_state_gate.py \
fixtures/list-invoices.openapi.yaml \
fixtures/list-invoices.bad.md
预期:非零退出码。JSON 报告应包含 wrong_default_zero、zero_collapsed_to_unlimited、null_equated_to_omit 和 invented_default:listInvoices:include_deleted。
一个干净的页面复述 schema 事实,并指向签名的 notes:
## Query parameters
- `limit` (integer, optional, schema default `20`, minimum `0`). Zero is a legal value; it is not unlimited. See owned notes.
- `cursor` (string, optional, not nullable). Omit for the first page. Do not send null.
- `include_deleted` (boolean, optional). No schema default. Do not equate omit with false.
Gate 不试图理解 invoices。它只是拒绝坍缩的状态。
提取。 读取 OpenAPI。用上方的表格对每个参数分类。写出 classified.json。
起草。 用分类后的行和一份禁止列表来 prompt 模型:除非 default 存在,否则不写默认值;不写"null 表示省略";不写"0 表示全部"。
扫描。 在草稿上运行 gate。任何错误码都导致 job 失败。
合并。 如果扫描通过,将草稿与签名的三态 notes 拼接。不要让模型重写 notes 文件。
发布。 只有合并后的文档是权威的。草稿文件用完即弃。
Prompt 文本应该枯燥。一份可用的 system prompt 是一份拒绝列表,而不是一份风格指南。
You draft parameter tables from classified.json only.
If default is missing, write "no schema default."
Never equate null with omit.
Never equate 0 with unlimited.
Never invent units, timezones, or retry rules.
If a human note file is provided, quote it; do not paraphrase defaults.
模型是一个坐在分类器上的格式化工具。它不是行为的数据源。
这项工作是分类加约束起草。它不需要长时间运行的 agent,也不需要模型去发明重试策略。
声明:本文是 MonkeyCode 产品推广的一部分。MonkeyCode 的免费模型访问和免费服务器选项足以在这类 fixture spec 上运行 extract-draft-scan 循环:模型填充表格,gate 拒绝坍缩的状态,签名的 notes 保持在重生成路径之外。这是唯一的产品角色。如果换成其他模型,所有权划分仍然有效。
如果你已经视 OpenAPI 为权威,有价值的实验是 gate,而不是一种新的 prose 风格。
Scanner 是基于短语匹配的。模型仍可能在正则没捕获到的句子中夹带一个错误的默认值。可以用 AST 检查生成的 MDX 来收紧它,或者要求每个默认值都作为带围栏的 schema.default 值出现。
OpenAPI 3.0 的 nullable 不等于 3.1 的 union type。上文的分类器不展开 oneOf / anyOf。如果你的 spec 对"string or null"使用了 union,要先扩展 classify() 再信任 gate。
Query 参数是最简单的情况。带有 additionalProperties、vendor extensions 和多态 discriminator 对象的 body 字段需要不同的 owned-notes 布局。不要把这个脚本当作通用 API linter 来复用。
Gate 不能证明服务器与 spec 一致。如果生产环境应用的是 50 而 OpenAPI 写的是 20,模型和人类 notes 都会一致地出错。Spec 漂移是另一套 pipeline 的问题。
如果你的文档是手写的、很少重新生成,跳过这个划分。假设草稿是一次性的 gate 只会增加仪式感。
如果法律或安全文本与参数表在同一个 Markdown 文件里,跳过它。那些文件根本不应该被模型写入。
如果你无法为三态 notes 指定负责人,跳过它。spec 旁边未签名的"behavior"注释会被覆盖,gate 无法区分已审查的句子和遗留的 prompt。
实际规则很窄。让模型起草表格。把 omit、null 和 zero 放在签名文件中。当这三种状态坍缩成一个形容词时,让重生成失败。