两个薄 adapter 让 judge 的坏格式能离线测;not-json 只是解析失败的一种,回退之外还有三条路。
只想检查 judge 返回坏格式时怎么办,难道也要先启动 Qdrant、Neo4j,再连一次模型?节点里混着外部调用,这种小检查也会变得很长。所以先把调用收进 backend/adapters/llm.py 和 backend/adapters/retrieve.py 两个入口。
两个 adapter 只收该收的
LLM adapter 管模型地址、key、角色对应的模型名和请求。追问与报告的 prompt 留在各自节点旁边。这两类东西改法不一样:
- 地址、key 和模型名每次请求都要用,收在一处,改一次就够;
- prompt 得连着它服务的问题和上下文读,改一句追问,应该能直接看到它在什么流程里用。
后一条直觉其实有名字。htmx 的文章把它叫 Locality of Behaviour:一段代码的行为,最好只读这一段就看得明白。那篇文章也承认,它和“按关注点分层”是互相拉扯的。我的分界线就是按“改的时候要看多大范围”划的:key 改了不用看流程,prompt 改了必须看流程。
检索 adapter 接 query 和 mode,返回统一字段。暂时只拆这些,不为还没接入的 provider 设计整套接口。这正是 Martin Fowler 写的 YAGNI 的适用范围:它只针对为假想功能预埋的能力,不针对让代码更好改的投入。拆两个入口属于后者,provider 抽象属于前者。
收口的第一个理由是测试
Alistair Cockburn 最初提 Ports & Adapters(六边形架构) 时,写在意图里的第一句就是让应用既能被用户驱动,也能被程序、自动化测试或批处理脚本驱动,并且能脱离真实设备和数据库来开发、测试。分层是为了能离线测。
他对端口的定义也对得上这里为什么是两个入口:
A port identifies a purposeful conversation.
向模型要一个判断,和向检索要一批片段,是两次目的不同的对话。合成一个通用 client,反而丢了这层区分。
入口固定下来,替身也好放了。pytest 的 monkeypatch 文档提醒,补丁要打在代码实际引用的那个名字上,from x import y 之后得改使用方的 y;它更推荐把依赖写成显式传入,而不是全局打补丁。有了 adapter,替身换的就是这一个入口,不用满模块找名字。
用替身测 not-json
九月整理测试时,test_llm_judge.py 里就有一个例子:先用测试替身让 chat 返回合法 JSON,再让它返回 not-json,两次都不调用真实模型。
async def fake_chat_bad(**kwargs): return "not-json"judge_json 解析失败后返回 sufficient=False,reason 以 judge_invalid_json 开头。测试检查这两个结果,就知道坏格式没有被当成“材料充分”。这里的 False 记录的是解析失败,不是模型认真判断过证据不足。
手写一个 async def 替身够用。标准库里也有现成的 AsyncMock,调用结果本身是 awaitable,还能记 await_count、await_args。但这个测试只要固定返回一段文本,不关心调用了几次,用 AsyncMock 反而要多配一行。
如果代码走的是 FastAPI 依赖注入,官方还有一条口子:app.dependency_overrides 是个字典,原依赖做 key、替身做 value,应用就改调替身。文档举的例子正是按次收费的外部服务。代价是代码得先写成注入式。
not-json 只是解析失败的一种
返回一段不是 JSON 的文本,只是最显眼的那种坏格式。官方文档里能找到好几种成因,都和材料本身无关:
- 截断:Azure OpenAI 的 JSON mode 文档说,解析前先看
finish_reason,是length就说明输出被 token 上限截断,可能只有半截 JSON,不要解析。 - 类型错、缺字段:Anthropic 的 strict tool use 文档举的例子是返回
"2"而不是2,或者漏掉必填字段。整段是合法 JSON,照样过不了解析。 - 模型自己猜:工具调用概览提到,提示里缺必填参数时,模型可能自己填一个看起来合理的值,而且官方说这个行为不保证。
我们的替身只覆盖了第一种里最粗的情形。这也是为什么 judge_invalid_json 值得单独计数:judge 本身就有噪声,MT-Bench 那篇论文测出 LLM 评判和人类偏好的一致率在 80% 以上,和人与人之间差不多,但同时列了位置偏差、冗长偏差、自我增强偏差。两类噪声来源不同,混进同一个 sufficient=False 的统计,事后就没法归因。
这个测试只管解析,不管循环
后面是否追加检索、到了上限是否停止,要另外检查 B 线控制逻辑。这两件事都发生在多轮之间:上一轮返回了什么、已经查过几轮,才决定要不要再查、什么时候停。这里的替身每次固定返回同一段文本,不带轮次信息,这个测试也没有验证跨轮的控制。
范围我觉得有必要说清楚,不然看见名字叫 judge 测试,会以为整条循环都验证过了。
检索那边也一样,mode 会直接影响结果。Qdrant 的 Query API 混合查询用 prefetch 先发子查询,稠密和稀疏的候选再用 rrf 或 dbsf 融合,融合方式不同,拿回来的候选集就不同。所以检索 adapter 的返回字段,最好能带上“这批是用哪种 mode 拿到的”。
回退之外还有三条路
解析失败直接回退成 sufficient=False,是最保守的一种。以后要动这块,还有三种做法,各有代价:
| 做法 | 怎么做 | 代价 |
|---|---|---|
| 回退(现在的) | 解析失败记为不充分,reason 标明原因 | 这一轮白跑 |
| 先修再解析 | json_repair 修尾逗号、少括号这类小错 | 坏输出被当正常输入收下,日志里看不出模型原本回了什么 |
| 带错重问 | Instructor 校验失败就把错误回灌给模型重试 | 多一次调用,一次格式事故变成多轮循环 |
| 从源头约束 | Structured Outputs 用 json_schema 加 strict: true |
受模型支持范围和 schema 子集限制,模型还可能直接拒答 |
注意 JSON mode 只保证输出是合法 JSON,不保证符合你的 schema,要 schema 保证得用 Structured Outputs。如果真走“带错重问”,前面说的跨轮控制就更要单独测:重试本身就是一个循环。
先看程序反应,再看内容
有了两个入口,可以分两步走:先用固定输入看程序反应,再接真实服务检查内容。模型没返回 JSON,先看解析路径;检索片段不相关,再查数据和检索。不用每次等一整套服务起来,才发现出错的只是一个字段。
留下几句话 读者来信
读到这里,有什么想法?欢迎留下你的看法。