前面的系列已经覆盖了模型调用、上下文组织、结构化输出、错误处理、提示词、评估和安全边界。现在做一个小型综合项目:输入一篇 Markdown,先让模型找出疑似链接问题,再由 Python 检查链接是否真的出现在原文中、字段是否完整,并输出一份可复核的报告。这个项目的重点不是让模型“自动改好文章”,而是把模型放在建议环节,把确定性的校验和最终决策留在程序中。

先定义项目边界

我们只处理两类链接:Markdown 链接 [文字](地址) 和裸 URL。模型需要为每个疑似问题返回 kind、target、reason 和 confidence 四个字段。kind 只能是 broken_syntax、suspicious_url 或 unclear;target 必须是原文中出现的链接目标;confidence 只能是 low、medium、high。

这个工具不直接访问每个网站,也不把模型的判断当成事实。URL 是否能访问,还需要单独的网络检查;某个地址看起来可疑,也不代表它一定失效。明确边界后,程序才能给出诚实的结果:它报告“模型建议”和“程序验证”,而不是宣称已经证明链接有效。

准备环境和输入

使用独立虚拟环境安装 OpenAI 官方 Python SDK:

1
2
3
4
5
python -m venv .venv
source .venv/bin/activate
python -m pip install openai
export OPENAI_API_KEY="替换为你的真实密钥"
export MODEL_NAME="替换为你可用的模型名称"

新建 article.md,放入待审查的 Markdown。密钥只从环境变量读取,不要写进文章、脚本或提交记录。模型名称也作为配置读取,避免把某个具体模型永久写死在代码里。

编写最小审查器

创建 link_auditor.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import json
import os
import re
import sys
from pathlib import Path

from openai import OpenAI


LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]+)\)|(?<!\()https?://[^\s)]+")


def extract_targets(markdown: str) -> list[str]:
targets = []
for match in LINK_RE.finditer(markdown):
target = match.group(1) or match.group(0)
targets.append(target.strip())
return targets


def ask_model(markdown: str) -> list[dict]:
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
response = client.responses.create(
model=os.environ["MODEL_NAME"],
instructions=(
"你是 Markdown 链接审查助手。只返回 JSON 数组。"
"每项必须有 kind、target、reason、confidence;"
"kind 只能为 broken_syntax、suspicious_url、unclear,"
"confidence 只能为 low、medium、high。"
"不要补造原文没有出现的 URL。"
),
input=markdown,
)
return json.loads(response.output_text)


def validate(items: list[dict], targets: list[str]) -> list[dict]:
allowed_kinds = {"broken_syntax", "suspicious_url", "unclear"}
allowed_confidence = {"low", "medium", "high"}
checked = []
for item in items:
if not isinstance(item, dict):
continue
if item.get("kind") not in allowed_kinds:
continue
if item.get("confidence") not in allowed_confidence:
continue
target = item.get("target")
if not isinstance(target, str) or target not in targets:
continue
if not isinstance(item.get("reason"), str) or not item["reason"].strip():
continue
checked.append(item)
return checked


def main() -> int:
if len(sys.argv) != 2:
print("用法:python link_auditor.py article.md", file=sys.stderr)
return 2
markdown = Path(sys.argv[1]).read_text(encoding="utf-8")
targets = extract_targets(markdown)
suggestions = ask_model(markdown)
report = validate(suggestions, targets)
print(json.dumps({"links": targets, "findings": report}, ensure_ascii=False, indent=2))
return 0


if __name__ == "__main__":
raise SystemExit(main())

运行:

1
python link_auditor.py article.md

这里的 extract_targets 是本地的确定性步骤,先从文章中提取允许出现的目标。ask_model 使用 Responses API,把审查规则放在 instructions 中,并通过 response.output_text 读取 SDK 提供的文本结果。模型返回的 JSON 仍然只是字符串,所以紧接着用 json.loads 解析;解析失败时程序会报错,而不是悄悄把自然语言当成审查结果。

validate 是第二道边界。它检查枚举值、必填字段和目标是否来自原文。即使模型凭空生成一个 URL,或者把 confidence 写成其他词,也会被丢弃。注意,这种校验不能证明模型判断正确,只能阻止一部分格式和范围错误。

为什么不直接让模型修改文章

自动改写会同时改变链接文字、段落结构和原文意图,失败后也不容易知道改动来自模型还是程序。审查器只输出报告,用户可以根据 target 定位原文,再人工决定是否修改。若未来需要自动修复,也应该先把修复建议保存为补丁,展示差异并经过确认,而不是覆盖原文件。

还可以把模型输出和输入摘要记录下来,形成可重放的样本。测试时使用固定 Markdown,验证以下条件:模型返回的未知 URL 会被过滤;缺少 reason 的项目不会进入报告;合法项目会保留。网络请求本身不应成为单元测试的前提,可以把 validate 单独测试。

常见问题

为什么不直接访问 URL? 因为访问会带来超时、重定向、权限、robots 规则和网络副作用。可以另写一个有超时、限并发和明确白名单的检查器,把 HTTP 结果作为独立证据,不要把“模型觉得可疑”和“服务器返回错误”混为一谈。

模型返回的不是 JSON 怎么办? 当前示例让 json.loads 失败并暴露问题,这是比静默接受更安全的行为。生产版本可使用 SDK 支持的结构化输出配置,但仍应查阅当前服务商文档确认参数和响应形状,并保留本地字段校验。

为什么 target not in targets 不能防止所有幻觉? 它只能过滤不在输入中的目标。模型仍可能对真实 URL 给出错误理由,因此报告必须标注为建议,并保留原文和规则版本,方便人工复核。

长文章会不会超出上下文窗口? 会。应先按标题或字符预算分块,并在报告中保存块编号;不能简单截断,否则可能把链接定义和引用分开。分块策略属于输入设计,应该用固定长文本测试边界。

小结

这个综合项目把一次模型调用放进了一个小而清晰的流水线:Python 提取输入,模型提出候选问题,JSON 解析和字段校验过滤结果,程序输出报告而不覆盖原文。模型适合处理“哪些地方值得注意”这类语义任务,程序适合处理来源、类型和必填字段等确定性约束。沿着这个边界继续扩展时,可以加入结构化响应、有限重试、URL 网络探测和人工审批,但每一步都应保留证据,避免把不确定的建议包装成确定结论。