Files
liqiang b119135836
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
2026-08-20 13:12:50 +00:00

544 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
实验 5-3 主程序:小模型靠"代码化规则"追平大模型的可靠性
三方对照(核心主张):
A. 小模型 + 代码化规则(实验组,三重保障)
B. 小模型 · 纯自然语言(控制组)
C. 大模型 · 纯自然语言(可选基线,--big-model 开启)
预期:A 的任务成功率 ≈ C,且都显著高于 B —— 即"把业务规则写成代码化守卫",
能让一个小模型在复杂政策执行上追平大模型裸跑的可靠性。
指标:
- 任务成功率:最终退款结果是否符合代码化政策真值。
- 政策违规次数:多退款(该拒不拒) + 该退不退,两个方向都算。
- 无效工具调用次数:被代码校验拒绝 / 未知预订等返回 error/rejected 的调用。
- expected_* 自报值 vs 数据库真值 不一致比例(仅实验组,验证服务端真值校验的必要性)。
常用跑法见 `--help``--selftest` 无需 API Key,离线演示三层守卫的代码化校验逻辑。
"""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import json
import math
import os
import sys
from pathlib import Path
try:
from dotenv import load_dotenv
load_dotenv() # 若存在 .env 则加载 OPENAI_API_KEY / MODEL / BIG_MODEL
except ImportError:
pass
from airline_env import AirlineEnv, is_refundable, SERVER_NOW
from agent import MODEL
from tasks import TASKS
ALT_KEYWORDS = ["改签", "信用", "credit", "保留", "替代", "积分", "凭证", "备选",
"rebook", "alternative", "voucher"]
POLICY_KEYWORDS = ["政策", "不可退", "24", "policy", "non-refundable", "not refundable"]
def judge(task, env, final_text) -> dict:
"""基于环境真值(状态即真值)做规则判据评分。"""
refunded = env.res.refund_issued > 0
expect_refundable = task.expect_refundable
wrongful_refund = (not expect_refundable) and refunded # 该拒不拒
wrongful_refusal = expect_refundable and (not refunded) # 该退不退
violation = wrongful_refund or wrongful_refusal
success = not violation
offered_alt = any(k.lower() in final_text.lower() for k in ALT_KEYWORDS)
explained_policy = any(k.lower() in final_text.lower() for k in POLICY_KEYWORDS)
if expect_refundable:
user_experience_ok = success and any(
k in final_text.lower() for k in ("refund", "退款", "cancel", "取消")
)
else:
user_experience_ok = success and offered_alt and explained_policy
return {
"task_id": task.task_id,
"expect_refundable": expect_refundable,
"refunded": refunded,
"success": success,
"wrongful_refund": wrongful_refund,
"wrongful_refusal": wrongful_refusal,
"invalid_tool_calls": env.invalid_tool_calls,
"offered_alt": offered_alt if not expect_refundable else None,
"explained_policy": explained_policy,
"user_experience_ok": user_experience_ok,
"checklist_records": env.checklist_records,
}
# ---------------------------------------------------------------------------
# 对照臂(arm):每一臂是 (模式, 模型) 的组合
# ---------------------------------------------------------------------------
def build_arms(small_model: str, big_model: str | None, mode: str) -> list[dict]:
"""按 --mode / --big-model 组装本次要跑的对照臂。默认(both、无大模型)
与旧版行为一致:控制组 + 实验组,均在小模型上。"""
arms: list[dict] = []
if mode in ("control", "both"):
arms.append({"key": "small_control", "mode": "control", "model": small_model,
"label": "小模型·纯自然语言", "role": "控制组"})
if mode in ("codified", "both"):
arms.append({"key": "small_codified", "mode": "codified", "model": small_model,
"label": "小模型+代码化规则", "role": "实验组"})
if big_model: # 可选第三臂:大模型裸跑基线(纯自然语言)
arms.append({"key": "big_control", "mode": "control", "model": big_model,
"label": "大模型·纯自然语言", "role": "大模型基线"})
return arms
def run_arm(
arm: dict,
tasks,
verbose: bool,
provider: str,
existing: dict[str, dict] | None = None,
checkpoint=None,
) -> list[dict]:
# 延迟导入 run_agent:仅在真正要调用模型时才需要(--selftest 不走这里)
from agent import run_agent
print(f"\n{'='*72}\n运行 [{arm['role']}] {arm['label']} 模型={arm['model']}\n{'='*72}")
existing = existing or {}
results = []
for task in tasks:
if task.task_id in existing:
results.append(existing[task.task_id])
print(f" ↻ {task.task_id:<26} reused from checkpoint")
continue
env = AirlineEnv(task.reservation)
out = run_agent(
env, task.user_message, arm["mode"], verbose=verbose,
model=arm["model"], provider=provider,
)
r = judge(task, env, out["final_text"])
r["source"] = task.source
r["final_text"] = out["final_text"]
r["transcript"] = out["transcript"]
r["messages"] = out["messages"]
r["provider_receipts"] = out["provider_receipts"]
r["duration_s"] = out["duration_s"]
results.append(r)
if checkpoint is not None:
checkpoint(arm["key"], r)
flag = "✅" if r["success"] else "❌"
detail = "多退款" if r["wrongful_refund"] else ("该退未退" if r["wrongful_refusal"] else "")
print(f" {flag} {task.task_id:<26} 应退={str(r['expect_refundable']):<5} 实退={str(r['refunded']):<5} "
f"无效调用={r['invalid_tool_calls']} {detail}")
return results
def summarize(results: list[dict]) -> dict:
n = len(results)
succ = sum(r["success"] for r in results)
violations = sum(r["wrongful_refund"] + r["wrongful_refusal"] for r in results)
invalid = sum(r["invalid_tool_calls"] for r in results)
ux = sum(r["user_experience_ok"] for r in results)
# expected_* vs 真值 一致性(合并所有 checklist 记录)
records = [rec for r in results for rec in r["checklist_records"]]
mism = sum(1 for rec in records if not rec["match"])
return {
"n": n, "success": succ, "success_rate": succ / n if n else 0.0,
"violations": violations, "invalid": invalid,
"user_experience_success": ux,
"user_experience_rate": ux / n if n else 0.0,
"checklist_total": len(records), "checklist_mismatch": mism,
}
def paired_analysis(control: list[dict], codified: list[dict]) -> dict:
if [r["task_id"] for r in control] != [r["task_id"] for r in codified]:
raise ValueError("control and codified task ids do not match")
control_only = sum(a["success"] and not b["success"] for a, b in zip(control, codified))
codified_only = sum(not a["success"] and b["success"] for a, b in zip(control, codified))
discordant = control_only + codified_only
if discordant:
tail = sum(math.comb(discordant, i)
for i in range(min(control_only, codified_only) + 1))
p_value = min(1.0, 2 * tail / (2 ** discordant))
else:
p_value = 1.0
control_rate = sum(r["success"] for r in control) / len(control)
codified_rate = sum(r["success"] for r in codified) / len(codified)
return {
"test": "two-sided exact McNemar/binomial test",
"n": len(control),
"control_only": control_only,
"codified_only": codified_only,
"discordant": discordant,
"p_value": p_value,
"control_success_rate": control_rate,
"codified_success_rate": codified_rate,
"success_rate_delta": codified_rate - control_rate,
"codified_significantly_higher": codified_rate > control_rate and p_value < 0.05,
}
def print_comparison(arms: list[dict], summaries: list[dict]):
print(f"\n{'#'*72}\n# 指标对比({len(arms)} 臂)\n{'#'*72}")
col = 24
label_w = 20
# 表头
header = f"{'指标':<{label_w}}" + "".join(f"{a['label']:<{col}}" for a in arms)
print(header)
print("-" * (label_w + col * len(arms)))
# 任务成功率
rate_cells = ["{}/{} = {:.0f}%".format(s["success"], s["n"], s["success_rate"] * 100) for s in summaries]
print(f"{'任务成功率':<{label_w}}" + "".join(f"{c:<{col}}" for c in rate_cells))
print(f"{'政策违规次数':<{label_w}}" + "".join(f"{str(s['violations']):<{col}}" for s in summaries))
print(f"{'无效工具调用次数':<{label_w}}" + "".join(f"{str(s['invalid']):<{col}}" for s in summaries))
ux_cells = ["{}/{} = {:.0f}%".format(
s["user_experience_success"], s["n"], s["user_experience_rate"] * 100
) for s in summaries]
print(f"{'用户体验代理成功率':<{label_w}}" + "".join(f"{c:<{col}}" for c in ux_cells))
# 核心主张的一句话解读(当同时有 实验组 与 大模型基线 时)
by_role = {a["role"]: s for a, s in zip(arms, summaries)}
if "实验组" in by_role and "大模型基线" in by_role:
exp, big = by_role["实验组"], by_role["大模型基线"]
print(f"\n[核心主张] 小模型+代码化规则 成功率 {exp['success_rate']*100:.0f}% "
f"vs 大模型裸跑 {big['success_rate']*100:.0f}%"
+ ("(追平/超过)" if exp["success_rate"] >= big["success_rate"] else "(尚有差距)"))
# expected_* 一致性(仅实验组存在 checklist)
exp_summ = by_role.get("实验组")
if exp_summ and exp_summ["checklist_total"]:
ratio = exp_summ["checklist_mismatch"] / exp_summ["checklist_total"]
print(f"\n[实验组] expected_* 自报值 vs 数据库真值:共 {exp_summ['checklist_total']} 次带 checklist 的取消调用,"
f"其中 {exp_summ['checklist_mismatch']} 次与真值不一致 —— 不一致比例 = {ratio*100:.0f}%")
print(" (说明:模型自我认知会出错;若无服务端真值校验,这些错误会直接变成违规操作。)")
def print_interception_example(exp_results):
"""找一例:实验组模型自报可退(expected_refundable=True),但数据库真值不可退,被代码拦截。"""
for r in exp_results:
for rec in r["checklist_records"]:
if rec["expected_refundable"] is True and rec["actual_refundable"] is False:
print(f"\n{'*'*72}\n* 代码化校验拦截示例({r['task_id']}\n{'*'*72}")
print(f"模型 checklist 自报:expected_refundable=True(认为可退)")
print(f"数据库真值 refundable=False,原因={rec['actual_reason']}")
for step in r["transcript"]:
if step["tool"] == "cancel_reservation":
print(f"\n模型发起取消调用:{step['args']}")
print(f"工具代码化校验返回:status={step['result'].get('status')}"
f"reason={step['result'].get('reason')}")
print(f" → {step['result'].get('message')}")
break
print(f"\n模型最终回复用户(被拦截后转为解释/提议替代):\n {r['final_text'][:400]}")
return True
return False
# ---------------------------------------------------------------------------
# 离线自检:无需 API Key,直接演示"三层守卫"里的第三层——服务端代码化校验
# ---------------------------------------------------------------------------
def run_selftest(tasks) -> None:
print(f"{'='*72}\n离线自检(无需 API Key):代码化退款政策 + 工具内真值校验\n"
f"服务端时钟 SERVER_NOW = {SERVER_NOW.isoformat()}\n{'='*72}")
for task in tasks:
r = task.reservation
truth, reason = is_refundable(r, SERVER_NOW)
print(f"\n[{task.task_id}] 舱位={r.cabin} 下单={r.booked_at.isoformat()} 航班状态={r.flight_status}")
print(f" 政策真值 is_refundable -> refundable={truth}, reason={reason}")
# 控制组"天真工具":无条件退款(代表没有代码化规则的系统)
env_naive = AirlineEnv(r)
naive = env_naive.cancel_reservation_naive(r.reservation_id)
print(f" [控制组·天真工具] status={naive['status']} 退款={env_naive.res.refund_issued}"
f" {'← 违规!政策不可退却退了' if (not truth and env_naive.res.refund_issued > 0) else ''}")
# 实验组"代码化工具":故意灌入与真值相反的 expected_refundable,看是否被拦截
env_cod = AirlineEnv(r)
wrong_expected = not truth # 模拟"模型自我认知出错"
cod = env_cod.cancel_reservation_codified(
r.reservation_id, expected_refundable=wrong_expected, expected_reason="airline_caused")
outcome = ("退款执行" if cod["status"] == "ok" else f"拒绝({cod.get('reason')})")
print(f" [实验组·代码化] 模型自报expected_refundable={wrong_expected} -> status={cod['status']} "
f"[{outcome}] 退款={env_cod.res.refund_issued}")
rec = env_cod.checklist_records[-1] if env_cod.checklist_records else None
if rec:
print(f" expected_* 校验:自报={rec['expected_refundable']} vs 真值={rec['actual_refundable']} "
f"-> {'一致' if rec['match'] else '不一致(已记录告警)'}")
print(f"\n{'='*72}\n结论:无论模型自报什么,实验组一律以数据库真值裁决——"
f"不可退的一律被拦截,可退的才放行。\n{'='*72}")
def select_tasks(patterns: list[str] | None, quick: bool):
tasks = TASKS
if patterns:
picked = [t for t in tasks if any(p.lower() in t.task_id.lower() for p in patterns)]
if not picked:
sys.exit(f"错误:--task {patterns} 未匹配任何 case。可用 task_id\n "
+ "\n ".join(t.task_id for t in TASKS))
return picked
if quick:
return tasks[:4]
return tasks
def build_parser() -> argparse.ArgumentParser:
ap = argparse.ArgumentParser(
prog="demo.py",
formatter_class=argparse.RawDescriptionHelpFormatter,
description=(
"实验 5-3:小模型通过代码化业务规则,追平大模型裸跑的政策执行可靠性。\n"
"基于 τ-bench 航空客服取消/退款场景,做三方对照实验。"),
epilog=(
"示例:\n"
" python demo.py # 默认:Qwen3-4B 两臂,跑全部 60 个 case\n"
" python demo.py --quick -v # 只跑前 4 个 case,并打印每步工具调用\n"
" python demo.py --task R009 # 只跑匹配 'R009' 的 case(核心拦截样例)\n"
" python demo.py --big-model gpt-5.6-luna # 加入第三臂:大模型裸跑基线,验证'小模型+规则≈大模型'\n"
" python demo.py --mode codified # 只跑实验组(with 代码化规则)\n"
" python demo.py --mode control # 只跑控制组(without 代码化规则)\n"
" python demo.py --small-model qwen3:4b --output result.json # 指定小模型并保存结果\n"
" python demo.py --selftest # 离线演示代码化校验逻辑(无需 API Key)\n"),
)
ap.add_argument("--mode", choices=["control", "codified", "both"], default="both",
help="跑哪一组:control=纯自然语言(without 代码化规则)codified=三重保障(with 代码化规则)"
"both=两组都跑(默认)")
ap.add_argument("--task", "--tasks", dest="task", nargs="+", metavar="ID",
help="只跑 task_id 匹配给定子串的 case(可多个,如 --task R003 R009")
ap.add_argument("--small-model", default=MODEL, metavar="NAME",
help=f"用作'小模型'的模型名(默认 {MODEL},也可用环境变量 MODEL 覆盖)")
ap.add_argument("--big-model", default=os.environ.get("BIG_MODEL"), metavar="NAME",
help="用作'大模型基线'的模型名(可选;给定后加跑第三臂:大模型裸跑纯自然语言)")
ap.add_argument(
"--provider", choices=["ollama", "openai", "openrouter", "moonshot", "ark"],
default="ollama", help="real inference provider; manuscript default is local Ollama",
)
ap.add_argument("--quick", action="store_true", help="只跑前 4 个 case(省钱快看)")
ap.add_argument("-v", "--verbose", action="store_true", help="打印每步工具调用")
ap.add_argument("--output", metavar="PATH", help="把逐 case 结果与汇总指标写入 JSON 文件")
ap.add_argument(
"--resume", action="store_true",
help="从 OUTPUT.checkpoint.json 恢复已完成的 (arm, case),每个 case 后原子更新 checkpoint",
)
ap.add_argument("--selftest", action="store_true",
help="离线自检:无需 API Key,直接演示代码化退款政策与工具内真值校验")
return ap
def _checkpoint_path(output: str) -> Path:
return Path(f"{output}.checkpoint.json")
def _checkpoint_identity(args, tasks, arms, protocol_sha256: str) -> dict:
return {
"experiment": "5-3",
"provider": args.provider,
"small_model": args.small_model,
"big_model": args.big_model,
"mode": args.mode,
"task_ids": [task.task_id for task in tasks],
"arm_keys": [arm["key"] for arm in arms],
"protocol_sha256": protocol_sha256,
}
def _load_checkpoint(path: Path, identity: dict) -> dict[str, dict[str, dict]]:
if not path.exists():
return {key: {} for key in identity["arm_keys"]}
payload = json.loads(path.read_text(encoding="utf-8"))
if payload.get("identity") != identity:
raise ValueError(
f"checkpoint identity mismatch for {path}; use the original arguments "
"or choose a new --output path"
)
stored = payload.get("results", {})
return {
key: {row["task_id"]: row for row in stored.get(key, [])}
for key in identity["arm_keys"]
}
def _write_checkpoint(path: Path, identity: dict, results_by_arm: dict[str, dict[str, dict]]) -> None:
payload = {
"schema_version": "1.0",
"identity": identity,
"updated_at_utc": dt.datetime.now(dt.timezone.utc).isoformat(),
"results": {
key: list(rows.values()) for key, rows in results_by_arm.items()
},
}
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
temporary.replace(path)
def _execution_completion(args, tasks, arms, arm_results) -> dict:
expected_ids = [task.task_id for task in tasks]
exact_rows = all(
[row["task_id"] for row in rows] == expected_ids
for rows in arm_results
)
receipts_complete = all(
row.get("provider_receipts")
and all(
receipt.get("response_id")
and receipt.get("response_model")
and isinstance(receipt.get("usage"), dict)
and receipt["usage"].get("total_tokens") is not None
for receipt in row["provider_receipts"]
)
for rows in arm_results
for row in rows
)
messages_complete = all(
isinstance(row.get("messages"), list) and row["messages"]
and isinstance(row.get("transcript"), list)
for rows in arm_results
for row in rows
)
exact_manuscript_model = (
args.provider == "ollama"
and args.small_model == "qwen3:4b"
and args.big_model is None
)
exact_arms = [arm["key"] for arm in arms] == ["small_control", "small_codified"]
exact_full_matrix = len(tasks) == 60 and expected_ids == [task.task_id for task in TASKS]
gates = {
"exact_qwen3_4b_local_ollama": exact_manuscript_model,
"exact_control_and_codified_arms": exact_arms,
"all_60_frozen_cases": exact_full_matrix,
"all_arm_case_rows_present_in_order": exact_rows,
"provider_receipts_and_usage_complete": receipts_complete,
"raw_messages_and_tool_transcripts_complete": messages_complete,
"server_ground_truth_scoring": True,
}
return {
"gates": gates,
"campaign_complete": all(gates.values()),
"required_trajectories": len(tasks) * len(arms),
"observed_trajectories": sum(len(rows) for rows in arm_results),
}
def main():
args = build_parser().parse_args()
tasks = select_tasks(args.task, args.quick)
# 离线自检:不需要 API Key,先处理
if args.selftest:
run_selftest(tasks)
return
if args.provider != "ollama" and not any(os.environ.get(name) for name in (
"OPENAI_API_KEY", "OPENROUTER_API_KEY", "MOONSHOT_API_KEY", "ARK_API_KEY"
)):
sys.exit("错误:未设置 OPENAI_API_KEY(或 OPENROUTER_API_KEY 兜底),请复制 env.example 为 .env 并填入,或直接 export。"
"\n(提示:想离线看代码化校验逻辑,可跑 `python demo.py --selftest`,无需 Key。)")
arms = build_arms(args.small_model, args.big_model, args.mode)
if not arms:
sys.exit("错误:没有可运行的对照臂,请检查 --mode / --big-model 组合。")
print(f"实验 5-3:小模型通过代码化知识提升执行规则的准确性")
print(f"共 {len(tasks)} 个 case(可退 {sum(t.expect_refundable for t in tasks)} / "
f"不可退 {sum(not t.expect_refundable for t in tasks)}),{len(arms)} 个对照臂:"
+ "、".join(f"{a['label']}({a['model']})" for a in arms))
protocol_path = Path(__file__).resolve().parent / "experiment_protocol.json"
protocol_sha256 = hashlib.sha256(protocol_path.read_bytes()).hexdigest()
results_by_arm: dict[str, dict[str, dict]] = {arm["key"]: {} for arm in arms}
checkpoint_path = _checkpoint_path(args.output) if args.output else None
if args.resume:
if not args.output:
sys.exit("错误:--resume 必须与 --output 一起使用。")
results_by_arm = _load_checkpoint(
checkpoint_path,
_checkpoint_identity(args, tasks, arms, protocol_sha256),
)
identity = _checkpoint_identity(args, tasks, arms, protocol_sha256)
def save_completed(arm_key: str, row: dict) -> None:
results_by_arm[arm_key][row["task_id"]] = row
if checkpoint_path is not None:
_write_checkpoint(checkpoint_path, identity, results_by_arm)
arm_results = []
for arm in arms:
rows = run_arm(
arm, tasks, args.verbose, args.provider,
existing=results_by_arm[arm["key"]],
checkpoint=save_completed,
)
arm_results.append(rows)
results_by_arm[arm["key"]] = {row["task_id"]: row for row in rows}
if checkpoint_path is not None:
_write_checkpoint(checkpoint_path, identity, results_by_arm)
summaries = [summarize(res) for res in arm_results]
print_comparison(arms, summaries)
# 拦截样例(取第一个实验组臂)
for arm, res in zip(arms, arm_results):
if arm["mode"] == "codified":
if not print_interception_example(res):
print("\n(本次运行实验组未出现 expected=可退/真值=不可退 的拦截样例;"
"可重跑或调高温度观察。)")
break
if args.output:
payload = {
"schema_version": "2.0",
"experiment": "5-3",
"generated_at_utc": dt.datetime.now(dt.timezone.utc).isoformat(),
"protocol": {
"path": "experiment_protocol.json",
"sha256": protocol_sha256,
"content": json.loads(protocol_path.read_text(encoding="utf-8")),
},
"config": {
"provider": args.provider,
"small_model": args.small_model, "big_model": args.big_model,
"mode": args.mode, "task_ids": [t.task_id for t in tasks],
},
"arms": [
{**{k: arm[k] for k in ("key", "mode", "model", "label", "role")},
"summary": summ, "results": res}
for arm, summ, res in zip(arms, summaries, arm_results)
],
}
by_key = {arm["key"]: res for arm, res in zip(arms, arm_results)}
if "small_control" in by_key and "small_codified" in by_key:
payload["paired_analysis"] = paired_analysis(
by_key["small_control"], by_key["small_codified"]
)
payload["completion"] = _execution_completion(
args, tasks, arms, arm_results
)
payload["official_complete"] = payload["completion"]["campaign_complete"]
if "paired_analysis" in payload:
payload["observed_performance_hypothesis"] = {
"codified_significantly_higher": payload["paired_analysis"]["codified_significantly_higher"],
"accuracy_delta": payload["paired_analysis"]["success_rate_delta"],
"p_value": payload["paired_analysis"]["p_value"],
"note": "A negative hypothesis result does not invalidate complete execution.",
}
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
with output_path.open("w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
print(f"\n结果已写入 {args.output}")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.exit(130)