我的博客

JSON Mode + Schema 校验——代码层的幻觉防线

Jul 28, 2026
6分钟
1174字

前言

在上一篇文章我们了解到Prompt 管意图,Code 管行为。但 Code 具体怎么管?这篇文章就是实操。

在 WorkMusic 项目里,我落地了三层代码防线:JSON mode 锁格式、Pydantic 锁字段、Token 计算锁成本。每一层都踩了坑——但踩完之后,我对”为什么生产级 Agent 不能只靠 prompt”有了切身体感。


零、整体流程图如下

![[代码层的三层防线流程 1.png]]

一、三层防线:从「能跑」到「可控」

先说一个反直觉的发现:在 WorkMusic 的 prompt 模板里,我已经在 system prompt 中写了输出格式要求,qwen2.5:7b 和 deepseek-r1:32b 不加 response_format 也能返回合法 JSON。那为什么还要加三层代码校验?

因为不同层保证的东西不同——少一层就是少一个兜底:

做法保证什么保证不了什么
Prompt你必须输出一个合法的 JSON 对象模型理解了你想要 JSON格式(可能套 markdown 代码块)、字段名、结构
JSON moderesponse_format={"type":"json_object"}语法合法(括号匹配、逗号位置)字段名、字段类型、缺失字段
PydanticCatalogResponse(**json.loads(raw))字段名、类型、必填、多余拒绝内容语义(song_name 可以是一段废话)

Prompt 给意图,JSON mode 锁语法,Pydantic 锁结构——每一层解决上一层的盲区。


二、JSON Mode:格式的强制约束

在我的 prompt 模板里,system prompt 末尾已经写了:

你必须输出一个合法的 JSON 对象,格式为 {“follow_up”: ”…”, “requirements”: ”…”, “candidates”: […]}。不要包含任何额外文字、markdown 代码块或注释。

实测中,qwen2.5:7b 和 deepseek-r1:32b 大部分情况下确实直接返回了干净 JSON。但当你加了 response_format 之后,多了一层底层保障:模型不会在外面套 markdown 代码块,也不会在 JSON 前后加解释文字。

1
# ollama.py — 流式请求中开启 JSON mode
2
stream = await self._client.chat.completions.create(
3
model=self._model,
4
messages=messages,
5
stream=True,
6
temperature=temperature,
7
response_format={"type": "json_object"},
8
)

但这只是格式控制。JSON mode 不关心你定义的字段叫 follow_up 还是叫 type,不关心 candidates 是不是 array——它只保证括号和引号是对的。

下一层来做这件事。


三、Pydantic:字段的精准校验

用 Pydantic 定义了输出 schema:

schema.py
1
from pydantic import BaseModel
2
3
class TrackCandidate(BaseModel):
4
song_name: str
5
hit_reason: str
6
estimated_price: str
7
8
class CatalogResponse(BaseModel):
9
model_config = {"extra": "forbid"} # 多了字段直接抛错
10
follow_up: str
11
requirements: str
12
candidates: list[TrackCandidate] | None

调用侧一行完成校验:

test_prompt.py
1
response = CatalogResponse(**json.loads(raw_output))
2
print("✅ 校验通过:", response.candidates[0].song_name)

就是这一行,从「能用」拉到了「可控」。

第一次跑的时候,模型把 hit_reason 写成了 match_reason。JSON mode 没拦——因为它确实是合法 JSON。但 Pydantic 直接抛出 20 个 validation error,每个精确到字段位置和缺失原因。JSON mode 最多告诉你 “这不是合法 JSON”,Pydantic 告诉你 “第 3 个候选对象少了一个叫 hit_reason 的字段”——精度差了一个数量级。

核心教训:两层都要有。 JSON mode 管格式(模型不许乱来),Pydantic 管字段(模型乱来了你能抓到)。


四、Token 计算:成本的透明审计

原本以为 Ollama 的 OpenAI 兼容接口跟官方一样会返回 usage 数据:

1
# ollama.py — 非流式调用
2
response = await self._client.chat.completions.create(
3
model=self._model,
4
messages=messages,
5
temperature=temperature,
6
)
7
return response.choices[0].message.content, response.usage

结果打完日志一看——Ollama 返回的 usage 全是零

1
CompletionUsage(completion_tokens=0, prompt_tokens=0, total_tokens=0)

不是代码写错了,是 Ollama 的兼容接口有结构没数据。生产级不能指望 provider 老老实实给你 token 数——你得自己兜底。

所以在 RouterClient 里加了零值检测,fallback 到字符估算:

RouterClient.chat_sync
1
reply, usage = await self._provider.chat_sync(safe, temperature)
2
if usage.prompt_tokens == 0:
3
# Ollama 不返回 usage,用字符估算代替
4
usage = type(usage)(
5
prompt_tokens=after,
6
completion_tokens=len(reply or "") // 4,
7
total_tokens=after + len(reply or "") // 4
8
)
9
return reply, usage

拿到 token 数后,对接定价表直接算成本:

token_counter.py
1
FEE_Config = {
2
"light": {"input": 2, "output": 8}, # ¥/百万 token
3
"middle": {"input": 2.5, "output": 10},
4
"heavy": {"input": 4, "output": 16},
5
}
6
7
def estimate_cost(prompt_tokens, completion_tokens, tier: str):
8
input_cost = prompt_tokens * FEE_Config[tier]["input"] / 1_000_000
9
output_cost = completion_tokens * FEE_Config[tier]["output"] / 1_000_000
10
return input_cost + output_cost

五、A/B 成本:花对了钱吗

回上一篇 A/B 测试数据:

版本promptcompletion成本 (light)
v0(纯约束)29286378¥0.0013
v1_fewshot6184941,112¥0.0052

Few-shot 多花了 2 倍 prompt token,但换来了 5 倍的输出长度和质量提升——绝对值不到 1 分钱,在真实场景中花费还是比较值的。

当然成本意识不是说单次便宜就行——而是你知道每一轮对话花了多少钱,以及为什么花。


结论

Prompt 告诉模型「做什么」。 JSON mode 保证它「说得格式对」。 Pydantic 保证它「说的字段对」。 Token 计算告诉你「说了多少钱」。

Code 层的幻觉防线 = 格式校验 + 字段校验 + 成本审计。 四层互为备份,少一层就是少一个兜底。

本文标题:JSON Mode + Schema 校验——代码层的幻觉防线
文章作者:vu-ji
发布时间:Jul 28, 2026
Copyright 2026
站点地图