P4.1 — Eval Gate 接线:字段级设计
设计文档。2026-07-08。据此写实施计划。scope = 最小可门禁(用户拍板)。基于亲验事实:
config-publish.service.ts/config-asset-version.entity.ts/agent/evals/domain/harness.py/docs/decisions/035-eval-sandbox-and-golden-sets.md。所有路径为仓库相对路径。
三个已定案决策(本设计遵守)
- golden scenario 存储 = 新独立
EvalGoldenScenario表(不塞 config-asset 底座)。真相源在 DB → 同步派生副本到 Langfuse dataset(同 P2.4 golden_answer→Haystack 的"真相源留底座 + 派生副本到外部")。 - scope = 最小可门禁:EvalRun 表 + EvalGoldenScenario 存储 + eval worker + T3 转移 + hallucination 单轴硬门禁(其余轴 advisory)+ gate 接线 + ADR-035 复议 + OD-13/D4 关闭。
- 跨服务协调 = 复用 agent harness:API worker(消费
publish_status='publishing')→ 触发 agent 侧现有harness.py(run_case/judge/aggregate)→ 收分数 → API 判 gate + 写 EvalRun。新增 agent 端点走 paired PR。
Non-goals(本期不做,明确留后)
- judge 人工校验 10-15 case(ADR-035 open item,需人工/analyst 标注锚点)——门禁先信任现有 judge,人工校验后续收紧。
- 多轴噪声带回归规则(跨 run 拉分数自比判回归;Langfuse 无内建原语,master plan §四 P4.1 边界(a))——本期只做 hallucination 单轴绝对门禁,不做"对比 baseline run 判回归"。
- Langfuse 看板全套 / experiments UI——本期 Langfuse dataset 同步是可选 advisory(见 DECISION POINT D-3);门禁判定第一版直接用 harness 分数,不依赖 Langfuse 在线。
- advisory 轴收紧为硬门禁——ADR-035 §5 "advise until confident, then tighten";本期只 gate hallucination。
1. EvalGoldenScenario 表(新建)
隔离归属(ADR-020 矩阵):平台级(platform-global),org_id IS NULL。 golden scenario 是平台评测集,不属于任何 client——不套 org×specialist 隔离。这是本表在 ADR-020 矩阵里的行,entity docstring 必须显式声明(否则触发"新实体无 docstring 命名其行"code-review block)。读取无 RLS specialist GUC 依赖;写入仅 SuperAdmin(管理评测集是平台运维职能)。
现有 3 个 JSON 数据集(agent/evals/domain/golden/{ecommerce_ops,marketing_content,policy_behaviors}.json)是 role-file 组织:{role, description, cases[]}。表设计一行一 case(scenario = case),role 作列,便于按 role 筛 + 增量加 case。
TABLE eval_golden_scenarios
id uuid PK default gen_random_uuid()
role text NOT NULL -- "ecommerce_ops" | "marketing_content" | "policy_behaviors" | ...
case_key text NOT NULL -- 原 JSON 的 case.id,如 "mkt-001-supplement-promo-compliance"
question text NOT NULL
golden_answer text NOT NULL
must_include jsonb NOT NULL default '[]' -- string[]
must_not_include jsonb NOT NULL default '[]' -- string[]
context text NULL -- grounded case 的 provided-knowledge 块(injection 模式)
-- seeded-KB 模式字段(#3873,eco-007+ 走生产检索):
kb_gold_docs jsonb NULL -- string[](期望命中的 doc)
kb_top_k int NULL
kb_dir text NULL -- KB fixture 目录名(seed 用,非绝对路径)
suite text NULL -- "policy" 等特殊 suite 标记(run.py 的 role-file vs suite 两类)
enabled boolean NOT NULL default true -- 软禁用某 case 不删
created_at timestamptz NOT NULL default now()
updated_at timestamptz NOT NULL default now()
CONSTRAINT eval_golden_role_case_uq UNIQUE (role, case_key) -- 幂等 seed / upsert
迁移(expand-contract,参考 1806700000000-CreateSpecialistConfigAssets.ts):CREATE TABLE IF NOT EXISTS + CHECK 约束(role/case_key/question/golden_answer NOT NULL 已由列声明,额外 CHECK char_length(role) between 1 and 64)。无 FK(平台级,不引用 org/specialist)。
3 数据集迁进表 = 一次性 seed 迁移(不是 importer)。理由:golden 是平台级、低频变更、由工程/analyst 维护;一个 seed 迁移读 3 个 JSON、INSERT ... ON CONFLICT (role, case_key) DO UPDATE 幂等灌入即可,不需要运行时 importer UI。DECISION POINT D-2:若将来 analyst 要自助加 case,再补 importer(现在 YAGNI)。JSON 文件保留作 agent harness 的 fixture 源 + seed 源(真相源迁 DB 后,harness 从 API 端点收 scenario,见 §4;JSON 退化为 seed/离线 fixture)。
2. EvalRun 表(新建 — eval_run_id 的目标)
config-asset-version.entity.ts:72-75 的 eval_run_id uuid nullable(悬空列,无 FK)指向本表。
TABLE eval_runs
id uuid PK default gen_random_uuid()
version_id uuid NOT NULL -- FK → specialist_config_versions.id
asset_id uuid NOT NULL -- 冗余便于按 asset 查(= version.assetId)
org_id uuid NOT NULL -- 被发布资产的 org(发布是 org-scoped 动作;golden 本身平台级但"这次发布跑的 eval"归属该 org 便于审计/计费)
status text NOT NULL -- "running" | "passed" | "failed" | "error"
gate_verdict text NULL -- "pass" | "block"(hallucination 单轴判定结果)
scenario_snapshot jsonb NOT NULL -- 本次跑的 scenario id 集合快照(哪些 case、什么版本)——可复现
scores jsonb NOT NULL default '{}' -- 逐轴:{hallucination:{failed:int,cases:[...]}, correctness_avg:float, alignment_avg:float, advisory:{...}}
hallucination_failures jsonb NOT NULL default '[]' -- 触发硬门禁的 case_key[](gate 判定的直接依据,冗余出来便于查询/审计)
langfuse_experiment_run_id text NULL -- Langfuse run 引用(advisory,可空——见 D-3)
error_detail text NULL -- status=error 时的原因
started_at timestamptz NOT NULL default now()
finished_at timestamptz NULL
CONSTRAINT eval_run_version_fk FOREIGN KEY (version_id)
REFERENCES specialist_config_versions(id) -- NOT VALID 后 VALIDATE(expand-contract)
ADR-020 归属:eval_runs 按 org_id 归属(row-5 审计类:审计跟随被发布资产的 org)。entity docstring 声明此行。version_id FK 补全 eval_run_id 悬空列的引用完整性(发布状态机 T2 成功后回填 version.evalRunId = run.id)。
gate 判定结果存哪:gate_verdict(pass/block)+ hallucination_failures[](判定依据)+ scores(全轴,advisory 轴也记但不影响 verdict)。审计链:config.publish.blocked audit payload 引用 evalRunId(取代现在的 evalGate:"not_wired")。
3. 发布状态机接 gate
现状(config-publish.service.ts:101-172):T1(draft→publishing 原子 claim :118)+ T2(publishing→published :129)+ 指针切换(:142)同一 tx 同步 pass-through,audit evalGate:"not_wired"(:158-163)。
改成异步 gate 流:
requestPublish:
[tx1] T1: claim draft→publishing (原子 :118 语义保留)
+ sibling-publishing 检查(见下)
+ 建 eval_runs 行 status="running"
[tx1 commit]
[post-commit] enqueue eval-gate job (BullMQ, jobId = version_id 幂等)
return { status: "publishing", evalRunId } -- 调用方看到 publishing,不再同步 published
eval-gate worker (消费 publishing):
1. 加载该 version 关联的 role → 取 EvalGoldenScenario(该 role 的 enabled cases)
2. 调 agent /eval/run(§4)→ 收 per-case CaseResult + aggregate
3. 判 gate:任一 case failure_class=="hallucination" → BLOCK;否则 PASS
(其余轴 correctness/alignment/violations 记进 scores 作 advisory,不影响 verdict)
4. 回写 eval_runs:status, gate_verdict, scores, hallucination_failures, finished_at
5. [tx2] 分叉:
PASS → T2: publishing→published + 指针切换 + version.evalRunId=run.id
audit config.publish { evalRunId, evalGate:"passed" }
BLOCK → T3: publishing→blocked + version.evalRunId=run.id
audit config.publish.blocked { evalRunId, evalGate:"blocked", hallucination_failures }
[tx2 commit]
[post-commit] 现有 golden-answer-sync enqueue 保持(仅 PASS 分支,publish 成功后)
sibling-publishing 检查(docstring :47-51 收口点):同步 pass-through 时"同一资产至多一个 publishing 版本"vacuously 满足(从不停在 publishing);async 后 T1 claim 前必须显式查 SELECT 1 FROM versions WHERE asset_id=$1 AND publish_status='publishing' AND id<>$2,命中 → 409(已有 sibling 在跑 eval)。兜底:PG partial unique index CREATE UNIQUE INDEX ... ON specialist_config_versions (asset_id) WHERE publish_status='publishing'(设计 spec §5.2 :245 要求,本期补)。
eval 不阻塞 live chat(ADR-019):旧 published 指针在 eval 跑期间继续服务;只有 T2 成功才切指针。blocked 版本从不成为 live。
T3 转移代码(现无,本期建):publishing→blocked 的原子 update(镜像 T2 的 update({id, publishStatus:"publishing"}, {publishStatus:"blocked"}))。blocked 唯一退出仍是现有 overridePublish(T4,强制 publish_note,现在因 blocked 不可达而 vacuous,本期让它真正可达)。
evalGate audit 值:"not_wired" → "passed" | "blocked"("running" 不落 audit,只在 eval_runs.status)。
4. 跨服务契约(paired agent PR)
agent 新增端点(agent/CLAUDE.md 硬约束 → paired PR):
POST /eval/run
auth: X-Agent-Secret (AGENT_SERVICE_SECRET,复用现有 agent 鉴权,见 D-4)
request:
{
"role": "marketing_content",
"scenarios": [ -- API 从 EvalGoldenScenario 取,传给 agent(真相源在 API,agent 不读 DB)
{ "id": "<eval_golden_scenario.id>",
"case_key": "mkt-001-...",
"question": "...",
"golden_answer": "...",
"must_include": [...],
"must_not_include": [...],
"context": null, -- injection 模式
"kb_gold_docs": null, "kb_top_k": null, "kb_dir": null }
]
}
response:
{
"role": "marketing_content",
"cases": [ CaseResult.as_dict() ], -- 复用现有 dataclass:id/correctness/verdict/
-- missing_required/forbidden_present/items/violations/
-- failure_class/unsupported_claims/rationale/...
"aggregate": { ... }, -- 复用现有 aggregate() 输出
"langfuse_experiment_run_id": "..." -- 若 agent 侧跑了 run_experiment(D-3),否则 null
}
复用:端点 handler 薄封装现有 harness.run_case(逐 case)+ harness.aggregate。不重实现 judge/aggregate。真相源不动:API 从 EvalGoldenScenario 表取 scenario 传给 agent(agent 无 DB 访问,符合服务边界);JSON fixture 退化为离线/seed 用途。
API worker → EvalRun 映射:cases[].failure_class=="hallucination" 聚合成 hallucination_failures[];aggregate + 各 case correctness/alignment 落 scores;gate_verdict = hallucination_failures 空则 pass 否则 block。
Langfuse run_experiment 在哪跑:Python SDK API → agent 侧(harness 是 Python)。但最小可门禁下 Langfuse 非必须(见 D-3):第一版 API worker 直接用 /eval/run 返回的 CaseResult 判 gate;Langfuse dataset 同步 + run_experiment 作为异步 advisory 看板后补,langfuse_experiment_run_id 可空。
5. hallucination 硬门禁判定
harness 已内建 hallucination 信号(harness.py CaseResult):
failure_class: "retrieval_miss" | "hallucination" | None(:191)——seeded-KB 模式下由judge_groundedness(RAGAS-faithfulness 风格,:285)算出unsupported_claims,caller 归因 hallucination vs retrieval_miss。- injection/
context模式(无 KB 检索):hallucination 体现为violations(must_not_include命中,judge 输出violationsmap :233)——如 goldenmkt-003的"a refund window other than 30 days"/"an invented policy detail not in the provided knowledge"。
硬门禁阈值(本期定义):一个 case 触发 hallucination 硬门禁当且仅当:
failure_class == "hallucination"(seeded-KB 模式,groundedness 判定有 unsupported_claims),或- injection 模式下
must_not_include里幻觉类违规命中(violations[k]==true,k 语义为"编造/invented/不实")。
DECISION POINT D-1:injection 模式怎么区分"幻觉类 must_not_include"vs"普通 must_not_include"(如 mkt-001 的"medical claim"是合规违规非幻觉)?本期务实默认 = 任一 violations 命中即进 advisory scores.violations,但只有 failure_class=="hallucination"(groundedness 路径)触发 BLOCK。即:seeded-KB grounded case 的 groundedness 是硬门禁的唯一权威信号;injection case 的 violation 全记 advisory 不 block。理由:groundedness 是 ADR-035 §4 明确的 hallucination 度量,语义无歧义;must_not_include 混合了合规/风格/幻觉多种语义,硬 block 会误伤。用户可推翻:若要 injection case 的"编造"违规也硬 block,需给 must_not_include 加语义标注(哪些是幻觉类)——那是 golden schema 扩展,留后。
其余轴 advisory:correctness(judge alignment 0-1)、must_include 覆盖(items)、非幻觉 violations、retrieval_miss——全记 scores,不影响 gate_verdict。
6. ADR-035 复议 diff
ADR-035 仍 Proposed(docs/decisions/035-eval-sandbox-and-golden-sets.md:3)。惯例:Proposed 未 Accepted → 改自身 Status/Decision(非 ADR-024 式 superseding ADR,那用于替换 Accepted 旧决策)。
:3Status:Proposed (for discussion — not yet accepted)→Accepted (2026-07-08; P4.1 wires the minimal gate)。:113Langfuse 段:**LangFuse**: **deferred.**→ 改为**LangFuse**: **adopted as sync-copy + runner/dashboard layer** (P4.1). Golden truth source stays first-party (EvalGoldenScenario table, ADR-020 platform-global); a derived copy syncs to a Langfuse dataset for run_experiment + trend dashboards. The PII/data-sovereignty objection is addressed by keeping the truth source in-DB — Langfuse holds only a synced copy. SDK v3 run_experiment now supports the CI-gate workflow (master plan §四 P4.1 注记 2026-07-07).(保留"gate the one unambiguous failure (hallucination)" hybrid 规则 :96,本期正是它)。- OD-13/D4 关闭:
docs/.../OPEN_DECISIONS.md:53-65(OD-13 golden-set 贡献格式)—— fold 结论进 ADR-035(Option 2 / Option B 已是既定格式,现有 3 数据集即证),删除 OPEN_DECISIONS 的 OD-13 entry。master plan:199D4 标记为关闭。
7. DECISION POINT(我替用户做的可逆默认,用户可推翻)
- D-1 hallucination 门禁信号:只有 groundedness(
failure_class=="hallucination")触发 BLOCK;injection case 的must_not_includeviolation 全记 advisory 不 block(避免误伤合规/风格类违规)。推翻成本:给 golden schema 的 must_not_include 加幻觉类标注。- ⚠️ 触发面数据(2026-07-09 亲验,用户暂离期确认 D-1 前必读):现有 21 个 golden case 里,只有 3 个是 grounded/seeded-KB(能走 groundedness → 触发硬门禁):ecommerce_ops 2 + marketing_content 1 + policy_behaviors 0。其余 18 个是 injection-only(只有 must_not_include,D-1 下全 advisory 不 block)。即这版硬门禁在现有 golden 上实际触发面 = 3/21。 但核实 policy_behaviors 的 must_not_include 内容后确认:它们是行为/语义描述(如 "guessing the topic and shipping a generic deck without asking"、"asking unnecessary clarifying questions when they were already given"),不是确定性字符串黑名单——拿子串匹配硬 block 会大量误判,且 pol-004/005 的"不该过度澄清"与 pol-001 的"不该不澄清"方向相反,是 judge 结合上下文的质量判断。所以 D-1(不把 must_not_include 硬化成门禁)技术上正确:这批 golden 本就是设计给 LLM-as-judge 多轴 advisory 评分的,"先 advisory 后收紧"是 ADR-035 §5 明确设计意图,非缺陷。这版 P4.1 的价值 = 接通 gate 基础设施(EvalRun/worker/T3/async 拆分),让门存在且可收紧;硬门禁触发面窄的真正解法是加 grounded golden case(留后),不是硬化语义 rubric。 用户回来若要更宽触发面 → 加 grounded case 或给 must_not_include 加幻觉类标注(都是 golden 侧扩展,不改 gate 架构)。
- D-2 golden 迁表方式:一次性 seed 迁移(
INSERT ON CONFLICT),非运行时 importer(YAGNI;analyst 自助加 case 时再补)。JSON 保留作 seed/离线 fixture 源。 - D-3 Langfuse 第一版必须与否:非必须。最小可门禁下 API worker 直接用
/eval/run的 CaseResult 判 gate;Langfuse dataset 同步 + run_experiment 作异步 advisory 看板后补,langfuse_experiment_run_id可空。推翻:若要 gate 判定走 Langfuse experiment,需 agent 侧引 Langfuse SDK + API 侧引用 experiment run(更重)。 - D-4 agent /eval/run 鉴权:复用现有
AGENT_SERVICE_SECRET(X-Agent-Secret),不新造 token。与现有 /chat 鉴权一致。 - D-5 EvalRun.org_id 归属:eval run 按被发布资产的 org 归属(审计/计费跟随发布动作),尽管 golden scenario 本身平台级。
8. 实施任务拆解(TDD 粒度,标 API-only / paired agent PR / 依赖)
| # | Task | 类型 | 依赖 |
|---|---|---|---|
| T1 | EvalGoldenScenario entity + 迁移(CREATE TABLE + CHECK + unique(role,case_key) + partial unique index on versions publishing)+ ADR-020 docstring | API-only | — |
| T2 | seed 迁移:3 个 golden JSON → eval_golden_scenarios(INSERT ON CONFLICT 幂等) | API-only | T1 |
| T3 | EvalRun entity + 迁移(FK version_id NOT VALID→VALIDATE)+ ADR-020 docstring | API-only | — |
| T4 | agent POST /eval/run 端点(薄封装 run_case/aggregate + request/response schema)+ evals 回归测试 | paired agent PR | — |
| T5 | API AgentClient.runEval()(调 /eval/run,X-Agent-Secret,fail-open) | API-only | T4 |
| T6 | eval-gate BullMQ worker(消费 publishing → runEval → 判 gate → 回写 EvalRun)+ QueueMetricsCollector 登记 | API-only | T3,T5 |
| T7 | config-publish.service async 拆分:T1 claim + sibling 检查 + 建 running EvalRun + enqueue;移除同步 T2 pass-through | API-only | T6 |
| T8 | T2/T3 分叉(worker 里 pass→published+指针 / block→blocked)+ evalGate audit 真值 + version.evalRunId 回填 | API-only | T7 |
| T9 | overridePublish 可达性验证(blocked→published + publish_note,现 vacuous → 真可达) | API-only | T8 |
| T10 | ADR-035 复议 diff(Status + Langfuse 段)+ OD-13/D4 关闭(删 OPEN_DECISIONS entry)+ config-assets/CLAUDE.md 契约更新(§5.3 从 not_wired 改真 gate) | 文档 | T8 |
| T11 | (留后 / 可选)Langfuse dataset 同步 + run_experiment advisory 看板 | paired | D-3 |
关键顺序:T1/T3 并行起 → T4(agent PR) 可与 T1-3 并行 → T5→T6→T7→T8→T9→T10。T4 是唯一 paired agent PR(先合或与 API 侧协调 wire)。