RAG Agent Benchmark 调优实战:从 85% 到 100%(答案 + 工具)

当你发现「答案对了,但工具选错了」,才算真正开始调优 RAG 系统。


前言:别急着调模型,先建 Benchmark

我们的系统是一个世界杯数据问答 Agent:FastAPI → Workflow Router → simple_qa / complex_flow / gossip,底层是 pgVector fact card + 分析视图(vw_*),上层是 LangChain Agent + 三级 Query Cache(L1 内存 / L2 Redis 精确 / L3 语义)。

功能跑通后,我们写了 20 条 Golden Case(球员统计、SQL 排行、语义检索、中国队等),用 benchmark/benchmark.py/chat 做端到端评测。

第一轮冷跑(--skip-cache)结果:

85% 答案、65% 工具——说明不少题「路走歪了但 LLM 兜底对了」,也有 3 条是路由进了 Mock 流程,答案直接错。

下面先汇总我们实际用到、且可复现的提准方法,再按踩坑顺序展开。


方法速览:我们用到哪些手段

#方法作用层解决什么问题
1Golden Benchmark + 自动化 runner评测每次改 Prompt/路由可回归,避免「感觉变好了」
2--skip-cache 冷跑评测避免 query cache 命中掩盖真实准确率
3expected_answer_groups 同义词组Golden「13 / 十三」「孙雯 / Wen Sun」都算对
4validate_sql + expected_sql_containsGolden不只判答案,还判 SQL 是否查了对的视图/条件
5prefers_simple_qa() 路由修正Router单实体统计题不再误进 complex_flow Mock
6Prompt Step 1–4 工具决策树Agent球员 → SQL → 语义 → 搜人,顺序固定
7工具 docstring 写边界Agent@tool 描述适用场景,不只写「执行 SQL」
8player_aliases.json 中文俗称数据/工具大罗、贝利、C罗 → 精确 player_id
9resolve_player_id 优先查 career card工具player_stats 先命中 fact card,再语义补充
10分析视图 vw_* + Prompt Few-shot SQLSQL不让 Agent 写不存在的 goals/players
11禁止表名黑名单 + 结构化 SQL 错误SQL错表直接返回 {"error": ...},Agent 换工具或改 SQL
12RESPONSE_RULES 禁止编造Agent无数据就说没有,不用「大概」凑数
13temperature=0LLM降低随机性,Benchmark 更可复现
14LangSmith trace(可选)观测看每步 tool call,定位选错工具
15is_player_compare() 路由Router梅西 vs C罗 双人对比进 simple_qa
16「一共几次 + 年份」路由Router意大利冠军届次不再进 Mock
17Prompt Step 0(评价/open 问法)Agent「梅西表现怎么样」优先 semantic
18expected_tools 多解 Golden评测SQL 或 semantic 都能答对的题允许多路径

尚未做、本文不当作已验证手段:Chunk 分块对比、Hybrid Search(tsvector + RRF)、SQL 自动 retry 循环、ROUGE/BERT 语义打分——可作为后续优化方向,但不写进本次 85%→100% 的路径。


问题一:路由误判——简单题进了 ComplexFlow Mock

现象

3 条失败 case 的共同特征:答案里是 【ComplexFlow · Mock 模式】,不是 Agent 正常输出。

问题误触关键词Mock 里发生了什么
2022 决赛几个进球多少个SQL 只 WHERE tournament_id='2022' LIMIT 10,没筛决赛
大罗生涯总进球总共走 semantic_search,没调 player_stats
中国女足射手王进球最多SQL 误用 competition='Men's' 男子榜

根因:COMPLEX_KEYWORDS 里「总共、多少个、进球最多」太宽,还没实现真正的 complex agent,Mock 流程却抢走了本该走 simple_qa 的题。

解决方案:prefers_simple_qa() 优先于 complex 关键词

workflows/route_keywords.py 里,对「单实体、可明确工具」的题强制走 simple_qa

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def prefers_simple_qa(query: str) -> bool:
    # 对比类仍走 complex_flow
    if any(kw in query for kw in ("对比", "谁更", "谁进球更多")):
        return False

    from tools import resolve_player_id

    # 具名球员 + 生涯/进球/总共 → player_stats
    if resolve_player_id(query) and any(kw in query for kw in (
        "进球", "进了", "生涯", "总共", "一共", "几届", ...
    )):
        return True

    # 某届决赛 + 进球数 → sql_query
    if re.search(r"20\d{2}", query) and "决赛" in query:
        if any(kw in query for kw in ("进球", "多少个", "几个")):
            return True

    # 女足/男足 + 射手王 → sql_query
    if ("女足" in query or "男足" in query) and "进球最多" in query:
        return True

    return False

效果:上述 3 条修复后,--skip-cache 冷跑 答案 20/20(100%);工具仍 15/20(75%)——见下文第二阶段。


问题二:Agent 工具选择——边界 + 决策顺序

现象

第一轮路由修复后,即使走对了 simple_qa,Benchmark 仍显示 工具正确率 75%(15/20)。典型分歧:

  • 「梅西表现怎么样」→ golden 期望 semantic_search,Agent 因出现「梅西」走了 player_stats(答案仍对)
  • 「梅西和 C罗谁进球更多」→ golden 期望 player_stats,路由进了 complex_flowsql_query(答案仍对)

说明:答案正确 ≠ 工具与 golden 一致,评测标准和 Prompt 优先级要分开看。

我们实际做的三件事

① 工具 docstring 写「干什么、不干什么」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@tool
def player_stats(name: str) -> str:
    """搜索球员世界杯生涯、进球、出场、奖项;支持中文俗称(贝利、大罗、C罗、梅西等)。"""

@tool
def sql_query(sql: str) -> str:
    """执行只读 SQL。仅可查询 vw_player_summary、vw_match_summary、
    vw_team_tournament_summary 或 documents/document_chunks。"""

@tool
def semantic_search(query: str) -> str:
    """语义搜索世界杯知识库,适合开放式问题和不确定该查什么表的问题。"""

② System Prompt 用 Step 顺序,不是散落的「建议」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Step 1 — player_stats(name)
  条件:已出现具体球员名或中文俗称,且问个人世界杯数据。

Step 2 — sql_query(sql)
  条件:答案是一个数字、排行、名单、合计,或按届次/球队/位置筛选的统计。

Step 3 — semantic_search(query)
  条件:描述性、评价性、开放式(「表现怎么样」「有没有参加过」)。

Step 4 — search_players(name)
  条件:球员身份不清,需先定位是谁。

③ 球员别名表,让 Step 1 真的能用中文问

etl/data/player_aliases.json + resolve_player_id():「大罗」→ P-62722(巴西 Ronaldo),再拉 worldcup-player_careers fact card。


问题三:SQL 生成——用视图,不用臆造表

现象

失败 case #2 若走 Mock,会生成:

1
2
SELECT match_id, home_team, away_team, score
FROM vw_match_summary WHERE tournament_id = '2022' LIMIT 10

M-2022-64stage_name ILIKE '%final%',Benchmark 的 expected_sql_contains 判失败。

根因

Agent 若按「通用足球库」想象,会写 goals / players / matches——这些表在本项目不存在

解决方案

① Prompt 只暴露 3 张分析视图 + 文档表,并给 Few-shot

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
- vw_player_summary:display_name, competition, team_codes[], goals, ...
- vw_match_summary:match_id, tournament_id, stage_name, goals, ...
- vw_team_tournament_summary:...

示例(2022 决赛总进球):
SELECT goals FROM vw_match_summary WHERE match_id = 'M-2022-64'

示例(中国女足射手王):
SELECT display_name, goals FROM vw_player_summary
WHERE competition = 'Women''s' AND team_codes @> ARRAY['CHN']
ORDER BY goals DESC LIMIT 5

② 代码层拒绝非法表名,返回可读错误

1
2
3
4
5
6
7
8
9
def execute_sql(sql: str):
    forbidden = _find_forbidden_table(sql)  # players, goals, matches, ...
    if forbidden:
        return {"error": f"Table '{forbidden}' does not exist. Use vw_player_summary, ..."}
    try:
        rows = execute_query(sql)
        return {"rows": rows, "row_count": len(rows)}
    except Exception as exc:
        return {"error": str(exc), "sql": sql}

Prompt 约定:看到 error 就改 SQL 或换 player_stats / semantic_search不要重复同一条错 SQL

修复后 #2 实际生成:

1
SELECT goals FROM vw_match_summary WHERE match_id = 'M-2022-64'

答案:6 个进球(常规时间 + 加时,不含点球大战)。


问题四:回答忠于工具数据

做法(已实现)

prompts.pyRESPONSE_RULES

1
2
3
- 工具返回空结果:可换工具重试一次;仍无数据再回复「抱歉,我没有找到相关数据,请换个问法试试。」
- 不要编造;不要用「可能」「大概」凑答案。
- 进球、场次等用整数,不随意四舍五入。

配合 temperature=0,减少 Benchmark 上的数字漂移。

我们没有加单独的「验证节点」LangGraph step;若幻觉仍多,可在此基础上再加自查 prompt 或结构化输出校验。


问题五:Golden 与 Benchmark 脚本怎么设计

① 答案打分:关键词 + 分组 OR

1
2
3
4
def score_answer(answer: str, case: dict) -> bool:
    # expected_answer_contains:任一命中
    # expected_answer_contains_all:全部命中
    # expected_answer_groups:每组至少命中一个(如 ["13","十三"])

Golden 示例:

② SQL 打分:片段必须出现

③ 冷跑命令

1
2
3
PYTHONPATH=. python3 benchmark/benchmark.py --skip-cache
# 改路由/Prompt 后务必清缓存或 skip_cache,否则可能读到旧的 Mock 答案
curl -X POST http://localhost:8000/cache/clear

最终结果

调优分两轮,--skip-cache 冷跑,数据来自 benchmark/result.tool-opt.json

第一轮:路由 + SQL Prompt(答案拉满)

路由修正 + Prompt/SQL 示例补全后:

  • 答案 100%:20 条 golden 全部通过。
  • 工具 75%:5 条「答案对但工具名与 golden 不一致」。
  • sql_required_miss: 1:「意大利几次冠军」仍被 complex_flow Mock 抢走。

第二轮:路线 A + C 落地后(当前)

实现 is_player_compare、冠军届次路由、Prompt Step 0、expected_tools 后:

  • 答案 100%工具 20/20(100%) 同时达成。
  • sql_required_miss: 0:意大利冠军届次走 simple_qa + sql_query
  • sql_valid_rate 8/9:仍有 1 条 SQL 未含 golden 要求的片段(答案仍对);与「工具 100%」不矛盾。
阶段答案工具延迟sql_required_miss
基线85%65%~5.1s1
第一轮(路由修复)100%75%~6.5s1
第二轮(A+C)100%100%~6.2s0

工具正确率:从 75% 到 100%(已实现)

答案到 100% 后,工具 75%(15/20) 说明 Benchmark 期望的「标准路径」和系统实际走的「有效路径」还没对齐。下面是当时 5 条 mismatch 及对应改法(均已落地)。

5 条 tool 不匹配一览

#问题golden 期望实际工具答案根因
4梅西和 C 罗谁进球更多player_statscomplex_flow → sql_query「和 + 谁更多」→ COMPLEX_WITH_AND,未进 prefers_simple_qa
5梅西世界杯表现怎么样semantic_searchplayer_statsPrompt Step 1:有球员名就优先 stats,与「评价/open 问法」冲突
112014 金手套得主player_statssql_querysemantic_searchAgent 先写 SQL 查奖项,失败再语义;golden 假定直接 stats
15意大利夺得过冠军吗semantic_searchsql_queryAgent 用 documents 查 Italy Winner,合理但 golden 标 semantic
16意大利几次冠军、哪些年sql_querycomplex_flow → semantic_search「一共几次」命中 COMPLEX_KEYWORDS,Mock 未生成 SQL

这 5 条里 没有一条是「答错」,而是 评测口径 vs 工程取舍 的差异。

路线 A:改路由(已实现)

① 对比题进 simple_qais_player_compare():双方别名都能 resolve_player_id 时走 simple_qa(修 #4)。

② 「一共几次 + 年份」进 simple_qa — 在 prefers_simple_qa() 中:

1
2
if "一共" in query and any(kw in query for kw in ("几次", "哪些年", "哪些年份", "哪几届")):
    return True

(修 #16)

路线 B:微调 Prompt(已实现)

Step 0 — 「怎么样 / 表现 / 评价」优先 semantic_search,即使用户提到球员名(修 #5)。

奖项题 — 金手套/金球得主优先 semantic_searchplayer_stats,不强行写复杂 SQL。

路线 C:Golden 多解(已实现)

benchmark.py 增加 match_expected_tool(),支持 expected_tools 列表(任一命中即 pass):

1
2
3
4
5
6
def match_expected_tool(case, tools_used):
    expected_any = case.get("expected_tools")
    if expected_any:
        return any(t in tools_used for t in expected_any)
    expected = case.get("expected_tool")
    return expected in tools_used if expected else None

Golden 示例(修 #11、#15):

实测:A + B + C 落地后,--skip-cache 冷跑 tool 20/20(100%),答案仍 100%

复现命令:

1
2
curl -X POST http://localhost:8000/cache/clear
PYTHONPATH=. python3 benchmark/benchmark.py --skip-cache

总结

  1. Benchmark 先行——20 case + runner,改一行就跑一遍。
  2. 先看路由,再看 Prompt——Mock workflow 误判的代价远大于「模型不够聪明」。
  3. SQL 用视图 + Few-shot + 黑名单——比塞 9 张原始表结构更有效。
  4. 中文 RAG 要做别名——否则 player_stats 在中文问题上不稳定。
  5. 分开看 answer 与 tool,再对齐口径——本轮最终 100% 答案 + 100% 工具;多路径题用 expected_tools 比死磕单一工具名更贴近工程现实。

调优不是一次性工作;每次加 workflow(session memory、complex agent 真实现)都要重新跑 --skip-cache,避免 cache 和 Mock 路径把指标「刷好看」。


附录:按改动文件对照

文件提准相关改动
benchmark/golden.json20 case、同义词组、expected_tools
benchmark/benchmark.pymatch_expected_toolvalidate_sql--skip-cache
workflows/route_keywords.pyprefers_simple_qa()is_player_compare()
workflows/router.pysimple_qa 优先于 complex 关键词
prompts.pyStep 0–4、SQL 视图示例、禁止编造
workflows/simple_qa.py四工具 docstring、LangChain agent
tools.pyresolve_player_idexecute_sql 黑名单与错误结构
etl/data/player_aliases.json中文俗称 → player_id