前言
在上一篇文章我们了解到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 mode | response_format={"type":"json_object"} | 语法合法(括号匹配、逗号位置) | 字段名、字段类型、缺失字段 |
| Pydantic | CatalogResponse(**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 mode2stream = 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:
1from pydantic import BaseModel2
3class TrackCandidate(BaseModel):4 song_name: str5 hit_reason: str6 estimated_price: str7
8class CatalogResponse(BaseModel):9 model_config = {"extra": "forbid"} # 多了字段直接抛错10 follow_up: str11 requirements: str12 candidates: list[TrackCandidate] | None调用侧一行完成校验:
1response = CatalogResponse(**json.loads(raw_output))2print("✅ 校验通过:", 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 — 非流式调用2response = await self._client.chat.completions.create(3 model=self._model,4 messages=messages,5 temperature=temperature,6)7return response.choices[0].message.content, response.usage结果打完日志一看——Ollama 返回的 usage 全是零:
1CompletionUsage(completion_tokens=0, prompt_tokens=0, total_tokens=0)不是代码写错了,是 Ollama 的兼容接口有结构没数据。生产级不能指望 provider 老老实实给你 token 数——你得自己兜底。
所以在 RouterClient 里加了零值检测,fallback 到字符估算:
1reply, usage = await self._provider.chat_sync(safe, temperature)2if 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 "") // 48 )9return reply, usage拿到 token 数后,对接定价表直接算成本:
1FEE_Config = {2 "light": {"input": 2, "output": 8}, # ¥/百万 token3 "middle": {"input": 2.5, "output": 10},4 "heavy": {"input": 4, "output": 16},5}6
7def estimate_cost(prompt_tokens, completion_tokens, tier: str):8 input_cost = prompt_tokens * FEE_Config[tier]["input"] / 1_000_0009 output_cost = completion_tokens * FEE_Config[tier]["output"] / 1_000_00010 return input_cost + output_cost五、A/B 成本:花对了钱吗
回上一篇 A/B 测试数据:
| 版本 | prompt | completion | 总 | 成本 (light) |
|---|---|---|---|---|
| v0(纯约束) | 292 | 86 | 378 | ¥0.0013 |
| v1_fewshot | 618 | 494 | 1,112 | ¥0.0052 |
Few-shot 多花了 2 倍 prompt token,但换来了 5 倍的输出长度和质量提升——绝对值不到 1 分钱,在真实场景中花费还是比较值的。
当然成本意识不是说单次便宜就行——而是你知道每一轮对话花了多少钱,以及为什么花。
结论
Prompt 告诉模型「做什么」。 JSON mode 保证它「说得格式对」。 Pydantic 保证它「说的字段对」。 Token 计算告诉你「说了多少钱」。
Code 层的幻觉防线 = 格式校验 + 字段校验 + 成本审计。 四层互为备份,少一层就是少一个兜底。