回顾 -> 新问题
- DeepRearchSystem 0x00:初识
- DeepRearchSystem 0x01:Agent 基础
- DeepRearchSystem 0x02:Graph 构建
- DeepRearchSystem 0x03:HITL
- DeepResearchSystem 0x04:MAS 进阶
至此,DeepResearchSystem 已经是一个多 Agent 协作的智能体研报系统。但是到此就结束了吗?用我们之前客户端的思维,一个产品从开发完成到上线之前往往还需要经历严格的测试,以保证产品可用性和性能。那么对于一个 Agent?如何确保其可用性呢?怎么去对一个 Agent 做测试?
解决
Agent 测试困境
回想一下以往我们客户端的测试方式:
- 分析业务场景可能对应的 case,创建测试用例
- 测试时拿程序执行结果去和测试用例的结果对比
但是对于 LLM Agent,输出并不是确定的。所以我们就不能靠以前传统的那套测试方式来应对 Agent。同时 Agent 的测试困境还不限于此:
-
输出非确定性:同个输入跑 N 次输出都不一样,没法用传统等值断言;
-
中间态不可观测:Agent 多步推理的中间结果(比如原始搜索内容、临时计划)默认不暴露,出问题没法定位;
-
因果链难追溯:最终报告出错,可能是计划、搜索、摘要任意环节的问题,定位成本可能是传统测试的10倍以上;
-
幻觉难界定:传统规则没法判断"无中生有"的语义幻觉,比如 Agent 编了不存在的政策条款;
-
环境依赖强:依赖搜索、工具调用等外部环境,测试结果波动大,反复调用外部API成本极高
困境突围
既然有这么多不确定性,显然不能像传统测试有一个比较确定的预期结果和测试用例。针对以上困境,业界比较常规的做法有:
-
端到端评估 :测整体任务完成度,用 LLM 去评估一次执行结果的完整性、准确性、信息覆盖度等
-
组件化评估:将 Agent 流程拆分成组件去评估,用解耦的思路去单独测试评估每个组件。比如,规划单独可测,搜索单独可测
-
混合策略 :确定性规则(比如引用 URL 是否存在、API调用是否成功)用传统测试,语义判断(比如内容相关性、逻辑合理性)用 LLM 去评估
-
Judge可靠性优化:用比 Agent 更强的模型当 Judge(避免同模型自我偏好),Prompt 里加 CoT 要求先给理由再打分,高风险场景用双模型交叉验证。
架构设计
LLM as Judge
其实上面说到的困境突围的方法就是业界所说的 LLM as Judge。
- LLM as Judge :用大语言模型作为自动化裁判的评估范式
- 定义:基于预设的结构化评分规则,让强 LLM 对 Agent 的输出、中间态进行语义级判断
- 本质:用 AI 的认知能力解决传统测试无法处理的模糊语义问题
LLM as Judge 和传统测试相比:
| 维度 | 传统测试 | LLM as Judge/ Agent测试 |
|---|---|---|
| 测试目标 | 验证功能正确性(代码是否按写好的逻辑跑) | 验证语义合理性/任务完成度(Agent 是否达成期望效果) |
| 断言逻辑 | 硬编码规则、等值断言 | 自然语言 Prompt 引导的概率判断 |
| 测试对象 | 确定性代码逻辑 | 概率生成的开放式输出 |
| 容错机制 | 零容忍,错一个用例就失败 | 允许合理波动,看统计均值 |
| 执行成本 | 毫秒级,几乎无额外成本 | 秒级/分钟级,依赖 LLM 调用成本 |
目标
ok,现在回到我们的 DeepResearchSystem,已经很明确我们需要使用 LLM as Judge 范式来对这套 Agent 系统做评估测试。我们的 Judge 模块必须具备的能力:
-
端到端评估 :测整体任务完成度,用 LLM 去评估一次执行结果的完整性、准确性、信息覆盖度等
-
组件化评估:将 Agent 流程拆分成组件去评估,用解耦的思路去单独测试评估每个组件。比如,规划单独可测,搜索单独可测。同时也能结合端到端评估在一次链路中找到评分拖后腿的模块。
分工
其实看这个 Judge 本质也是一个 Agent,核心作用是对我们的业务 Agent 做评估。是 Agent 我们在设计上就要考量到谁编排,谁执行。我们核心设计以下三个核心类:
-
evaluator: 负责编排(Orchestration):决定测什么、按什么顺序测。 -
judger: 负责执行(Execution):怎么测、怎么打分。 -
hook_context: 负责采集(Collection):底层数据捕获。
评估粒度
-
端到端 :
- 输入:研究主题(Topic)。
- 过程:完整运行 DeepResearch Agent,生成调研报告。
- 评判:使用
Judger(基于强 LLM )对最终报告的以下指标进行打分:- 事实准确性
- 覆盖度
- 逻辑性
- 时效性
- 引用质量
-
组件级 :
- 利用
HookContext对WebSearchAgent.step方法进行Monkey-Patch(猴子补丁) ,无侵入地捕获中间态数据(如原始搜索结果 vs AI生成的摘要)。- **Monkey-Patch **:本质上就是
hook,和我们 iOS 里的 "黑魔法"------方法交换同理
- **Monkey-Patch **:本质上就是
- 对Agent的每一个关键节点 进行独立评分:
- 计划生成
- 查询构建
- 摘要总结
- 反思批判
- 引用标注
- 计划反思
- 利用
-
模拟Human-in-the-loop:
- 通过
TopicCfg中 Mock 的user_feedback和expected_intent,模拟用户对研究计划的反馈,专门评估 Agent 的- 意图识别能力
- Replanning(重规划)质量。
- 通过
其他技术细节
- Prompt-模型-输出的强绑定 :每个评估维度都有独立的 Propmt 模板,每个 Propmt 模板对应各自的 Pydantic 评分模型,映射关系严格
- Prompt 定义评估规则、维度、输出格式;
- Judger 调用LLM生成输出;
- Pydantic模型强制校验输出结构,自动转换数据类型(比如把字符串分数转成float)
- LLM 输出治理 。不直接使用
json.loads解析,使用自定义的解析方式,用于去除无关文本,减少 Token 浪费。比如输出可能包含 "好的,以下是评估结果:". - 异常隔离 :单个 topic 运行失败只会记录
E2EResult,不会中断整个批次评估 - 环境一致性。全链路复用业务Agent的调用逻辑,保证评估环境和真实运行环境完全一致
- Monkey-Patch 机制,保障业务代码零侵入的前提下捕获中间态来进行测试
- 业务回调解耦
WebSearchCapture是纯业务类,只负责存储原始搜索结果post_hook_handler严格遵守HookContext回调契约,不阻断原方法返回值
- 中间态解耦映射 ,原始搜索
raw_results和Agent生成的摘要phase2_state["web_search_result"]一一对应 - 双模独立 。
run_e2e和run_components运行完全解耦,可独立评估。 - 结果双写 。
format_eval_report:生成人类可读的文本摘要,包含平均分、各维度得分、典型错误,方便研发快速定位问题save_eval_report:保存全量JSON数据,包含所有原始中间态、评分细节、逐条归因信息,方便后续做数据分析(比如统计幻觉的分布、引用错误的常见原因、不同topic的难度系数)
整体架构图
综上,我们设计的评估系统就是一套面对 DeepResearchSystem 的 「可观测-可量化-可归因」评估框架,核心架构分为四层,每层完全解耦,从底到顶分别是:
csharp
┌─────────────────────────┐
│ 编排调度层 │ ← evaluator.py:批量调度、异常隔离、报告生成
├─────────────────────────┤
│ 评估执行层 │ ← evaluator.py:端到端/组件级评估逻辑、中间态关联
├─────────────────────────┤
│ 评分标准化层 │ ← judger.py + judge_prompts.py:LLM-as-Judge调用、结构化输出约束
├─────────────────────────┤
│ 数据采集层 │ ← hook_context.py + WebSearchCapture:无侵入中间态捕获
└─────────────────────────┘
工作流程图
评估模块的执行流程为:

Coding
废话少说上代码。
evaluator(编排)
TopicCfg(评估主题配置)
topic:研究主题。Agent 的入口,也是 judge 的核心initial_search_query_count:搜索主题数量max_research_loops:调研的最大循环次数user_feedback:用户反馈。用于模拟 HITLexpected_intent:预期内容。用于模拟 HITL
Python
@dataclass
class TopicCfg:
"""
每个主题的评估运行配置。
"""
topic: str
initial_search_query_count: int = 5
max_research_loops: int = 3
user_feedback: str | None = None
expected_intent: str | None = None
初始化
Python
# ---------- 评估编排器 ----------
class Evaluator:
"""
评估编排器
"""
def __init__(self, judge_model_id: str | None = None):
self.judger = Judger(model_id=judge_model_id)
E2E 端到端
数据类
Python
@dataclass
class E2EResult:
"""
端到端评估结果
"""
topic: str
report: str = ""
sources: str = "" # JSON 序列化的 sources_gathered
score: E2EScore | None = None
error: str | None = None
执行函数
Python
# --- 端到端 ---
def run_e2e(self, topics: list[TopicCfg]) -> list[E2EResult]:
"""
对每个主题运行完整的 agent 并对最终报告进行评分。
"""
results: list[E2EResult] = []
for i, cfg in enumerate(topics):
logger.info(f"端到端 [{i + 1}/{len(topics)}] 主题={cfg.topic[:80]}...")
try:
# 调用 研究 Agent
result = self._invoke_search_agent(cfg)
if result.error:
results.append(result)
continue
# 调用真正的 LLM 评估器
result.score = self.judger.evaluate_report(
research_topic=cfg.topic,
search_sources=result.sources,
report=result.report,
)
results.append(result)
logger.info(
f" 总评分={result.score.overall_score if result.score else '无'}"
)
except Exception as exc:
logger.error(f"端到端评估失败 '{cfg.topic[:60]}': {exc}")
results.append(E2EResult(topic=cfg.topic, error=str(exc)))
return results
hook_context:hook黑魔法
这里本质就是实现了一个类似 iOS 的hook黑魔法。这里我们简单实现了一个通用的monkey-patch框架。
通用monkey-patch框架
Pyth
class HookContext:
"""
通用 monkey-patch 上下文管理器
"""
def __init__(self,
target: Tuple[Any, str],
post_hook: Optional[Callable]):
"""
Args:
target: (宿主对象, 方法名)
post_hook: 调用后执行,签名: (result, args, kwargs) -> result
"""
self.target = target
self.post_hook = post_hook
# 线程安全的原始方法存储
self._local = threading.local()
def _create_wrapper(self, original_func):
"""
创建包装函数(Wrapper)。
Hook 的灵魂:它包裹了原始函数,并在前后插入钩子。
"""
@functools.wraps(original_func)
def wrapper(*args, **kwargs):
# 执行原方法
try:
# 1. 调用原始函数(Original Call)
# 这一步就像接力棒,交还给原来的逻辑
result = original_func(*args, **kwargs)
except Exception as e:
if self.error_hook:
self.error_hook(e, args, kwargs)
raise
# 2. 后置处理(Hook Logic)
# 拿到结果后,执行我们定义的钩子(比如记录日志、采集数据)
if self.post_hook:
result = self.post_hook(result, args, kwargs)
return result
return wrapper
def __enter__(self):
"""
进入 `with` 代码块时自动调用。
"""
obj, name = self.target
# [关键点 1: 备份原件]
# 读取当前 obj 下的 name 属性(即原始方法),存入线程本地存储
self._local.original = getattr(obj, name)
# [关键点 2: 注入替身] <--- 这就是你问的"替换的方法"
# 创建一个包裹了原方法的新函数,并将其赋值给 obj.name
wrapper = self._create_wrapper(self._local.original)
setattr(obj, name, wrapper)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""
退出 `with` 代码块时自动调用。
"""
obj, name = self.target
# [关键点 3: 还原替身] <--- 这就是 unpatch!
# 将之前备份的原始方法重新赋值回去
setattr(obj, name, self._local.original)
# 清理线程本地存储,防止内存泄漏
del self._local.original
# 返回 False 表示不吞掉异常,让异常继续向外抛出
return False
Hook 调用
替换方法定义
首先需要用一个额外的类来实现替换方法,同时这个类也是连接业务类和 HookContext的直接纽带,业务类并不直接感知HookContext。
Pythion
class CaptureHook(ABC):
"""
捕获钩子的抽象基类(接口)
"""
@abstractmethod
def get_context(self) -> AbstractContextManager:
"""
返回一个上下文管理器,用于 with 语句
"""
pass
# 具体的 WebSearchCapture 适配器
class WebSearchCapture:
"""
业务实体:负责捕获搜索结果。
通过 monkey-patch 注入 WebSearchAgent 的线程局部捕获容器。
它只是一个普通的 Python 类,不依赖任何 Hook 框架。
"""
def __init__(self):
self.raw_results: List[Dict[str, Any]] = []
def get_context(self) -> AbstractContextManager:
"""
实现协议:返回一个激活 HookContext 的上下文管理器
"""
return HookContext(
target=(agent.WebSearchAgent, "step"),
post_hook=self.post_hook_handler
)
def post_hook_handler(self, result: Any, args: tuple, kwargs: dict) -> Any:
"""
符合 HookContext 约定的回调函数。
注意:这个方法之所以叫 handler,是因为它是被动调用的。
HookContext 约定了参数签名 (result, args, kwargs)。
"""
# 业务假设:WebSearchAgent.step(self, prompt)
if len(args) > 1:
prompt = args[1]
self.raw_results.append({
"query": prompt,
"pages": result
})
return result # 必须返回 result,这是 Hook 契约的一部分
def get_last(self) -> Dict[str, Any] | None:
return self.raw_results[-1] if self.raw_results else None
业务调用
这里_invoke_search_agent_feedback 函数式真正调用 Agengt,从这里传入capture来无侵入地捕获数据。
Python
# 1. 实例化业务捕获器
capture = WebSearchCapture()
# 2. 调用 Agent,注入钩子
agent_result = self._invoke_search_agent_feedback(
cfg,
capture_hooks=[capture] # 注入插座
)
Component 组件级
数据类
Python
@dataclass
class ComponentResult:
topic: str
plan_score: PlanScore | None = None
plan_query_alignment_score: PlanQueryAlignmentScore | None = None
query_score: QueryScore | None = None
summarization_scores: list[SummarizationScore] = field(default_factory=list)
critique_score: CritiqueScore | None = None
citation_score: CitationScore | None = None
plan_reflection_score: PlanReflectionScore | None = None
error: str | None = None
执行函数
Python
# --- 组件级 ---
def run_components(self, topics: list[TopicCfg]) -> list[ComponentResult]:
"""
对每个主题独立评估各个 agent 节点。
"""
results: list[ComponentResult] = []
for i, cfg in enumerate(topics):
logger.info(f"组件级 [{i + 1}/{len(topics)}] 主题={cfg.topic[:80]}...")
try:
results.append(self._eval_components(cfg))
except Exception as exc:
logger.error(f"组件级评估失败 '{cfg.topic[:60]}': {exc}")
results.append(ComponentResult(topic=cfg.topic, error=str(exc)))
return results
Hook Agent 执行过程获取数据,真正从了六大维度评估打分。
Python
def _eval_components(self, cfg: TopicCfg) -> ComponentResult:
result = ComponentResult(topic=cfg.topic)
# 1. 实例化业务捕获器
capture = WebSearchCapture()
# 2. 调用 Agent,注入钩子
agent_result = self._invoke_search_agent_feedback(
cfg,
capture_hooks=[capture] # 注入插座
)
plan_a = agent_result["plan_a"]
plan_b = agent_result["plan_b"]
actual_behavior = agent_result["actual_behavior"]
phase2 = agent_result["phase2_state"]
effective_plan = plan_b if plan_b else plan_a
# --- 评估计划 ---
if effective_plan:
result.plan_score = self.judge.evaluate_plan(
research_topic=cfg.topic,
plan=effective_plan
)
# --- 评估计划反思 ---
if cfg.user_feedback and cfg.expected_intent:
result.plan_reflection_score = self.judge.evaluate_plan_reflection(
original_plan=plan_a,
user_feedback=cfg.user_feedback,
new_plan=plan_b,
actual_behavior=actual_behavior,
expected_intent=cfg.expected_intent,
)
# --- 评估搜索查询 ---
search_queries = phase2.get("search_query", [])
query_list = list(search_queries) if isinstance(search_queries, list) else []
if query_list:
result.query_score = self.judge.evaluate_queries(
research_topic=cfg.topic,
queries=query_list,
rationale="(内部推理未捕获;参见计划上下文)",
)
if effective_plan:
result.plan_query_alignment_score = (
self.judge.evaluate_plan_query_alignment(
plan=effective_plan, queries=query_list
)
)
# --- 评估摘要保真度(核心改进:按 Query 关联)---
web_search_results = phase2.get("web_search_result", [])
summary_list = web_search_results if isinstance(web_search_results, list) else [web_search_results]
for query, summary in zip(query_list, summary_list):
if not query or not summary:
continue
# 通过业务键(Query)精确获取数据,不再依赖脆弱的索引
raw_pages_list = capture.get_raw_pages_by_query(query)
if not raw_pages_list:
print(f"[WARN] Raw pages missing for query: {query}")
continue
# 通常一个 query 对应一次搜索,取首个结果
raw_pages = raw_pages_list[0]
score = self.judge.evaluate_summarization(
search_query=query,
raw_search_results=json.dumps(raw_pages, ensure_ascii=False, indent=2),
summary=str(summary),
)
if score:
result.summarization_scores.append(score)
# --- 评估反思 ---
is_sufficient = phase2.get("is_sufficient")
if is_sufficient is not None:
result.critique_score = self.judge.evaluate_critique(
research_topic=cfg.topic,
summaries="\n---\n".join(str(s) for s in summary_list),
is_sufficient=bool(is_sufficient),
knowledge_gap=phase2.get("knowledge_gap", ""),
follow_up_queries=phase2.get("follow_up_queries", []),
)
# --- 评估引用 ---
report = agent_result["report"]
if report:
sources = agent_result["sources"]
if sources and sources != "[]":
result.citation_score = self.judge.evaluate_citations(
sources=sources, report=report
)
return result
数据捕获
端到端不需要 Hook,我们以组件级评估为例分析下数据获取和组件评估的链路。
capture:Hook函数,就好比 Agent 执行过程的监视器 or 录像带agent_result:执行结果的快照,和 业务逻辑数据解耦_eval_one_components:会看录像,按标准打分
Python
[_invoke_agent_with_feedback 执行阶段]
├─ HookContext.__enter__ (启动监控)
├─ graph.invoke (Plan A 生成)
├─ graph.invoke (Feedback/Replan)
├─ graph.invoke (Research/Search) --> WebSearchAgent.step() 被 Hook
│ ↓
│ [捕获动作] capture.raw_results.append(...)
│ (数据存入内存,但不打断流程)
├─ graph.invoke (Final Report)
└─ HookContext.__exit__ (关闭监控)
[函数返回] agent_result 字典包含所有状态(plan_a, phase2_state等)
↓
[_eval_one_topic_components 评估阶段] (这是另一个独立的阶段!)
├─ result.plan_score = judge(agent_result["plan_a"]) <-- 拿返回值打分
├─ result.query_score = judge(agent_result["search_query"])
├─ for query in search_queries:
│ raw = capture.get_raw_pages_by_query(query) <-- 拿 Hook 存下来的数据打分
│ result.summarization_scores = judge(raw, summary)
└─ return result
实现代码如下:
Python
def _invoke_search_agent_feedback(
self,
cfg: TopicCfg,
capture_hooks: List[CaptureHook] | None = None # 新增:钩子插槽
) -> dict:
"""调用 agent,可选择在计划确认阶段模拟用户反馈。
支持注入捕获钩子以拦截内部调用(如搜索)。
返回一个包含以下键的字典:
plan_a、plan_b、actual_behavior、report、sources、phase2_state
"""
config = {
"configurable": {
"thread_id": f"eval-{hash(cfg.topic + (cfg.user_feedback or '')) & 0xFFFF}",
"number_of_initial_queries": cfg.initial_search_query_count,
"max_research_loops": cfg.max_research_loops,
}
}
# ---- 阶段 1:触发计划生成 ----
phase1_state = graph.invoke(
{"messages": [HumanMessage(content=cfg.topic)]},
config=config,
)
plan_a = phase1_state.get("plan", "")
# 准备上下文栈(用于管理多个钩子)
stack = ExitStack()
try:
# 如果传入了钩子,在进入主逻辑前全部激活
if capture_hooks:
for hook in capture_hooks:
stack.enter_context(hook.get_context())
# ---- 核心逻辑:开始监控 ----
if cfg.user_feedback is None:
# 向后兼容的两阶段自动确认流程
phase2_state = graph.invoke(
{
"messages": [
HumanMessage(content=cfg.topic),
*(phase1_state.get("plan_messages", [])),
HumanMessage(content="需求确认"),
],
"plan": plan_a,
"plan_status": "confirmed",
},
config=config,
)
actual_behavior = "direct_proceed"
plan_b = ""
else:
# ---- 阶段 2:发送用户反馈 ----
phase2_state = graph.invoke(
{
"messages": [
HumanMessage(content=cfg.topic),
*(phase1_state.get("plan_messages", [])),
HumanMessage(content=cfg.user_feedback),
],
"plan": plan_a,
"plan_status": "confirmed",
},
config=config,
)
plan_status_after_p2 = phase2_state.get("plan_status", "")
if plan_status_after_p2 == "unconfirmed":
actual_behavior = "replan_then_proceed"
plan_b = phase2_state.get("plan", "")
# ---- 阶段 3:确认重新计划后的结果(研究) ----
# 注意:这里不再需要手动 patch/unpatch
phase2_state = graph.invoke(
{
"messages": [
HumanMessage(content=cfg.topic),
*(phase2_state.get("plan_messages", [])),
HumanMessage(content="需求确认"),
],
"plan": plan_b,
"plan_status": "confirmed",
},
config=config,
)
elif any(kw in cfg.user_feedback for kw in ["需求确认", "开始研究"]):
actual_behavior = "direct_proceed"
plan_b = ""
else:
actual_behavior = "llm_proceed"
plan_b = ""
# ---- 核心逻辑:结束监控 ----
finally:
# 确保无论成功或异常,所有钩子都被正确关闭(Unpatch)
stack.close()
# 提取最终报告
messages = phase2_state.get("messages", [])
report = ""
for msg in reversed(messages):
if isinstance(msg, AIMessage) and msg.content and len(msg.content) > 200:
report = msg.content
break
sources = json.dumps(
phase2_state.get("sources_gathered", []),
ensure_ascii=False,
indent=2,
)
return {
"plan_a": plan_a,
"plan_b": plan_b,
"actual_behavior": actual_behavior,
"report": report,
"sources": sources,
"phase1_state": phase1_state,
"phase2_state": phase2_state,
}
评估结果
数据类
Python
@dataclass
class EvalReport:
"""
评估报告
"""
timestamp: str
e2e_results: list[E2EResult] = field(default_factory=list)
component_results: list[ComponentResult] = field(default_factory=list)
报告格式化
python
# ---------- 报告格式化 ----------
def format_eval_report(report: EvalReport) -> str:
"""
渲染一份人类可读的评估摘要。
"""
lines = ["=" * 72, " DeepResearch Agent 评估报告", "=" * 72, ""]
# 端到端摘要
if report.e2e_results:
lines.append("--- 端到端报告得分 ---")
lines.append("")
scores = []
for r in report.e2e_results:
if r.score:
scores.append(r.score)
lines.append(f" 主题: {r.topic[:80]}")
lines.append(f" 总评分: {r.score.overall_score:.1f}/5")
lines.append(f" 事实准确性: {r.score.factual_accuracy.score}/5")
lines.append(f" 信息覆盖度: {r.score.information_coverage.score}/5")
lines.append(f" 逻辑结构: {r.score.logical_structure.score}/5")
lines.append(f" 时效性: {r.score.timeliness.score}/5")
lines.append(f" 引用质量: {r.score.citation_quality.score}/5")
lines.append(
f" 幻觉: {'有' if r.score.hallucination_check.get('has_hallucinations') else '无'}"
)
lines.append("")
elif r.error:
lines.append(f" 主题: {r.topic[:80]} 错误: {r.error[:120]}")
lines.append("")
if scores:
avg = sum(s.overall_score for s in scores) / len(scores)
lines.append(f" ** 平均总评分: {avg:.1f}/5 (n={len(scores)}) **")
lines.append("")
return "\n".join(lines)
报告保存
python
# 保存报告
def save_eval_report(report: EvalReport, path: str = "eval_report.json") -> None:
"""
将完整评估数据保存为 JSON 文件以供进一步分析。
"""
def _serialize(obj):
if hasattr(obj, "model_dump"):
return obj.model_dump()
if hasattr(obj, "__dict__"):
return obj.__dict__
return str(obj)
with open(path, "w", encoding="utf-8") as f:
json.dump(report, f, default=_serialize, ensure_ascii=False, indent=2)
logger.info(f"完整评估报告已保存至 {path}")
评估执行
数据类
这里才是真正评估的类,负责根据指标进行打分。所以先需要定义评分的数据结构。
Python
# ---------- Judger 结构化输出 ----------
# 打分类
class JudgeScore(BaseModel):
score: int = Field(ge=1, le=5)
reason: str
# E2E评分 对应 E2E_JUDGE_INSTRUCTIONS 标准
class E2EScore(BaseModel):
factual_accuracy: JudgeScore # 事实准确性
information_coverage: JudgeScore # 信息覆盖度
logical_structure: JudgeScore # 逻辑性
timeliness: JudgeScore # 时效性
citation_quality: JudgeScore # 引用质量
overall_score: float # 总分
overall_assessment: str # 整体评价
hallucination_check: dict # 幻觉检查
class PlanScore(BaseModel):
requirement_coverage: JudgeScore
question_clarity: JudgeScore
structure_quality: JudgeScore
overall_score: float
missing_dimensions: list[str] = Field(default_factory=list)
assessment: str
class QueryScore(BaseModel):
coverage: JudgeScore
independence: JudgeScore
search_friendliness: JudgeScore
overall_score: float
missing_angles: list[str] = Field(default_factory=list)
assessment: str
class SummarizationScore(BaseModel):
factual_fidelity: JudgeScore
key_info_extraction: JudgeScore
source_attribution: JudgeScore
overall_score: float
hallucinations: list[str] = Field(default_factory=list)
assessment: str
class CritiqueScore(BaseModel):
sufficiency_judgment: JudgeScore
gap_identification: JudgeScore
follow_up_query_quality: JudgeScore
overall_score: float
is_sufficiency_correct: bool
assessment: str
class CitationPerRef(BaseModel):
"""单条引用审计记录。"""
url: str
label: str = ""
paragraph_summary: str = ""
source_title: str = ""
status: str = "" # valid | weak | content_mismatch | url_not_found
reason: str = ""
class CitationSummaryStats(BaseModel):
valid_rate: float = 0.0
most_common_issue: str = ""
worst_offender_url: str = ""
class CitationScore(BaseModel):
total_citations: int
valid_citations: int
weak_citations: int = 0
invalid_citations: int
per_citation: list[CitationPerRef] = Field(default_factory=list)
citation_accuracy_score: int = Field(ge=1, le=5)
summary_stats: CitationSummaryStats | None = None
assessment: str
class PlanQueryAlignmentScore(BaseModel):
coverage_consistency: JudgeScore
plan_fidelity: JudgeScore
structural_decomposition: JudgeScore
overall_score: float
covered_dimensions: list[str] = Field(default_factory=list)
missed_dimensions: list[str] = Field(default_factory=list)
cross_reference_table: list[dict] = Field(default_factory=list)
assessment: str
class PlanReflectionScore(BaseModel):
intent_recognition: JudgeScore
feedback_incorporation: JudgeScore
overall_score: float
actual_behavior: str = ""
assessment: str
核心函数
Python
# ---------- Judger 类 ----------
class Judger:
"""
传入评分提示词调用 LLM 评价
"""
def __init__(self, model_id: str | None = None):
self.model = model_id or os.getenv("EVAL_MODEL", os.getenv("JUDGE_MODEL", ""))
if not self.model:
# 回退到可用模型列表中的最后一个模型
self.model = get_judge_model_id()
logger.info(f"Judge 已初始化,模型={self.model}")
def _call(self, prompt: str) -> dict[str, Any]:
"""
调用 LLM 评估。
"""
agent = Agent(model_id=self.model)
last_raw = ""
for attempt in range(3):
try:
raw = agent(prompt)
last_raw = raw
json_str = JsonUtils.extract_pattern(raw, pattern="json")
result = json.loads(json_str)
_sys.stderr.write(f"[Judge] 第 {attempt + 1} 次尝试成功,"
f"解析出 {len(result)} 个顶层键\n")
return result
except Exception:
_sys.stderr.write(
f"[Judge] 第 {attempt + 1} 次尝试失败\n"
f" raw[:500]: {last_raw[:500]}\n"
f" 错误: {traceback.format_exc()}\n"
)
continue
_sys.stderr.write("[Judge] 全部 3 次尝试均失败,返回 {}\n")
return {}
E2E 评估
Python
# -- 端到端 --
def evaluate_report(
self, *, research_topic: str, search_sources: str, report: str
) -> E2EScore:
prompt = _safe_format(E2E_JUDGE_INSTRUCTIONS,
research_topic=research_topic,
search_sources=search_sources,
report=report,
)
result = self._call(prompt)
return E2EScore(**result) if result else None
component 评估
其实和 E2E 评估差不多,只是变换了 Prompt 模板和返回的数据结构。这里以 计划评估为例。其他几个维度类似。
Python
# -- 组件级评估 --
def evaluate_plan(self, *, research_topic: str, plan: str) -> PlanScore:
"""
评估 计划 部分
:param research_topic:
:param plan:
:return:
"""
prompt = _safe_format(
PLAN_JUDGE_INSTRUCTIONS,
research_topic=research_topic,
# 上下文截断,防止 LLM 出现严重的位置偏见。这里更好的方式是先进行摘要
plan=plan[:8000]
)
result = self._call(prompt)
return PlanScore(**result) if result else None
Prompt
其实评估这块还有个核心就是提示词的编写,这里可参考之前 Prompt 撰写。分别以两个维度做个示例吧。
端到端
Python
PLAN_JUDGE_INSTRUCTIONS = """
# 角色说明
你是一名专业的科研评审专家,核心任务是对一份AI生成的研究报告开展标准化质量评估,所有评估结论必须客观、有依据,严格遵循给定的评分规则与输出要求。
# 任务说明
评估AI生成的研究计划的合理性。研究计划应该在开始搜索前帮助澄清用户需求。
# 评分维度
1. **需求覆盖率 (Requirement Coverage)**: 是否覆盖了5大关键要素?(1-5分)
2. **问题清晰度 (Question Clarity)**: 追问是否精准、具体、有引导性?(1-5分)
3. **结构合理性 (Structure Quality)**: 计划是否清晰可执行?(1-5分)
# 评分要求
1. 逐段核对报告内容与搜索来源:排查报告中是否存在虚构的数据、事件、引用,标记所有无来源支撑的错误陈述
2. 对照研究主题梳理报告覆盖的信息点:统计报告覆盖了哪些核心维度,遗漏了哪些关键内容
3. 梳理报告的章节架构:判断标题层级是否合理、论证逻辑是否连贯、各部分是否存在逻辑递进关系
4. 核查报告中所有数据、案例、信息的发布时间:判断是否采用了符合主题要求的近期信息,是否存在信息陈旧问题
5. 逐一检查报告中的引用:判断引用来源是否权威可信、标注是否清晰规范、引用内容是否与来源匹配
6. 对照5个评分维度的评分标尺,初步确定每个维度的得分与对应理由,核算总分,梳理报告的整体优缺点与幻觉情况
# 输出格式
\```json
{
"requirement_coverage": {"score": 4, "reason": "..."},
"question_clarity": {"score": 4, "reason": "..."},
"structure_quality": {"score": 3, "reason": "..."},
"overall_score": 3.67,
"missing_dimensions": ["维度1", "维度2"],
"assessment": "整体评价..."
}
\```
# 研究主题
{research_topic}
# 生成的计划
{plan}
# 输出"""
Component- 计划-查询对齐性
Python
PLAN_QUERY_ALIGNMENT_JUDGE_INSTRUCTIONS = """
# 角色定位
你是专业的学术研究搜索策略评估专家,擅长精准判定研究计划与对应生成搜索查询的衔接匹配质量,
能够严格按照统一标准完成量化评分、维度校验与结构化结果输出,所有评估结论均需基于给定的研究计划与搜索查询内容,
不得加入主观臆断或外部信息。
# 任务说明
评估从研究计划到搜索查询的衔接质量。一个好的研究计划应该能自然地派生出覆盖全面的搜索查询。
针对输入的「研究计划」与「基于该计划生成的搜索查询」,输出标准化、可追溯的评估结果,帮助判断搜索查询是否能够支撑研究计划的落地执行,
识别查询存在的覆盖缺失、边界偏离、拆解不合理等问题。
# 评分维度
1. **覆盖一致性 (Coverage Consistency)**: 搜索查询是否覆盖了研究计划中列出的所有关键维度?(1-5分)
- 5分(完全覆盖):研究计划中提取的每一个关键维度,均有至少1条对应的搜索查询支撑,无遗漏维度
- 4分(基本覆盖):仅遗漏1个非核心关键维度,80%及以上的关键维度有对应查询支撑
- 3分(部分覆盖):遗漏2个关键维度,或仅覆盖60%-79%的关键维度
- 2分(覆盖不足):遗漏3个及以上关键维度,仅覆盖30%-59%的关键维度
- 1分(严重缺失):大部分(≥60%)计划关键维度在查询中没有对应体现,无法支撑研究开展
- 评分要求:评分理由需明确说明「计划共包含多少个关键维度、查询实际覆盖多少个、具体遗漏的维度名称」。
2. **计划忠实度 (Plan Fidelity)**: 搜索查询是否忠实于计划的边界定义(时间范围、媒体范围、分析重点等)?(1-5分)
- 5分(完全遵循):所有搜索查询均严格符合计划设定的全部约束条件,无超出边界、遗漏约束的情况
- 4分(轻微偏离):仅1条查询存在非核心约束的轻微偏离,不影响整体搜索方向,无核心约束违反
- 3分(部分偏离):2-3条查询存在约束偏离,或存在1项核心约束(如时间范围、核心研究对象)违反,可能导致部分搜索结果不符合需求
- 2分(严重偏离):超过3条查询存在约束偏离,或违反2项及以上核心约束,近半数搜索结果可能偏离计划要求
- 1分(完全偏离):大部分查询超出计划边界或忽略重要核心约束,搜索结果无法匹配研究计划需求
- 评分要求:评分理由需明确说明「计划明确的约束条件有哪些、哪几条查询违反了哪项约束、具体偏离表现是什么」;若研究计划未设定某类约束,不得作为扣分项。
3. **结构化拆解 (Structural Decomposition)**: 搜索查询是否对计划进行了合理的分解,而非简单照搬计划中的标题?(1-5分)
- 5分(拆解优秀):所有查询均将对应计划维度细化为具体、可搜索的明确问题,无简单照搬计划标题的情况,查询之间无语义重叠、冗余
- 4分(拆解良好):大部分查询完成了合理细化,仅1条查询存在轻微照搬情况,或仅存在1组非核心语义重叠,不影响搜索效率
- 3分(拆解合格):半数左右查询完成了合理细化,存在2-3条照搬计划标题的查询,或2组语义重叠,会造成一定的搜索冗余
- 2分(拆解较差):超过半数查询为计划标题的简单复制,或存在3组及以上语义重叠,搜索效率低,无法获取精准信息
- 1分(拆解无效):所有查询均只是计划标题/原文的简单复制,无任何细化拆解,无法直接用于搜索
- 评分要求:评分理由需明确说明「哪些查询做了合理细化、哪些查询存在照搬问题、哪些查询之间存在语义重叠」。
# 输出格式
\```json
{
"coverage_consistency": {"score": 4, "reason": "计划中有5个关键维度,查询覆盖了4个,遗漏了'风险研判'维度"},
"plan_fidelity": {"score": 3, "reason": "计划要求聚焦2025年,但查询1和查询3未包含时间限定"},
"structural_decomposition": {"score": 4, "reason": "查询基本合理拆解了计划维度,但查询2与查询3存在语义重叠"},
"overall_score": 3.67,
"covered_dimensions": ["维度1", "维度2"],
"missed_dimensions": ["维度3"],
"cross_reference_table": [
{"plan_dimension": "核心分析对象-产品对比", "matching_queries": ["查询1", "查询3"], "coverage": "full"},
{"plan_dimension": "对手策略维度", "matching_queries": ["查询2"], "coverage": "partial"},
{"plan_dimension": "风险研判维度", "matching_queries": [], "coverage": "missed"}
],
"assessment": "整体评价:计划到查询的衔接质量中等,主要问题是..."
}
\```
# 研究计划
{plan}
# 生成的搜索查询
{queries}
# 输出"""
Judge Running
Judge CLI
一个简单的针对性的 Agent 评估框架就基本写完了。接下来就是怎么用?怎么更好地用。
我们的 Judge 支持端到端和组件级评估,且二者运行是独立解耦的。可以单独运行,也可以独自运行。接下来我们就构建一个 Judge CLI 来支持已经实现好的 DeepResearchSystem。
在整个框架中,Judge CLI 的角色是:

Judge CLI 的核心流程:

Codding
初始化
Python
parser = argparse.ArgumentParser(
description="DeepResearchSystem Judge CLI",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
modde
Python
parser.add_argument(
"--mode",
choices=["e2e", "comp", "all"],
default="e2e",
help="评估模式:e2e(端到端)、comp(组件级)、all(两者都运行)",
)
model
Python
parser.add_argument(
"--judge-model",
type=str,
default=None,
help="Judge LLM 的模型 ID(默认使用环境变量 EVAL_MODEL 或最后一个可用模型)",
)
topic
Python
parser.add_argument(
"--topic",
type=str,
default=None,
help="要评估的单个研究主题(覆盖测试集)",
)
其他参数
Python
parser.add_argument(
"--output",
type=str,
default=None,
help="保存完整 JSON 评估报告的路径",
)
parser.add_argument(
"--test-set",
type=str,
default="test_eval.json",
help="测试集 JSON 文件的路径(相对于 eval/ 目录)",
)
parser.add_argument(
"--initial-queries",
type=int,
default=None,
help="覆盖所有主题的 initial_search_query_count",
)
parser.add_argument(
"--max-loops",
type=int,
default=None,
help="覆盖所有主题的 max_research_loops",
)
parser.add_argument(
"--feedback",
type=str,
default=None,
help="模拟用户在计划确认阶段的反馈(仅用于单主题模式)",
)
parser.add_argument(
"--expected-intent",
type=str,
default=None,
choices=["proceed", "replan"],
help="预期系统行为:proceed(确认并继续)或 replan(修改计划)",
)
使用示例
Python
# 在所有测试主题上运行端到端评估
python -m eval.run_eval --mode e2e
# 对单个主题进行端到端评估
python -m eval.run_eval --mode e2e --topic "你的研究主题"
# 组件级评估
python -m eval.run_eval --mode comp
# 两种模式都运行
python -m eval.run_eval --mode all
# 指定 judge 模型
python -m eval.run_eval --mode e2e --judge-model qwen3.7-max
# 输出到文件
python -m eval.run_eval --mode all --output eval_results.json
测试用例
Python
{
"description": "测试用例集 --- 用于评估 DeepResearchSystem 输出质量",
"topics": [
{
"topic": "未来近五年 AGI 发展前景和预测分析",
"domain": "科技",
"difficulty": "medium",
"initial_search_query_count": 3,
"max_research_loops": 3,
"expected_key_facts": [
"AI 核心技术演进,包括算力-算法的协同进步",
"各模型厂商技术进展和发展重点",
"AGI 的商业化落地清况",
"企业 vs 个人开发者的定价策略"
]
},
{
"topic": "2026年人民币汇率走势分析及主要影响因素",
"domain": "金融",
"difficulty": "medium",
"initial_search_query_count": 3,
"max_research_loops": 3,
"expected_key_facts": [
"美联储货币政策走向",
"中国央行汇率管理措施",
"中美利差变化",
"进出口数据和经常账户状况",
"主要机构对人民币汇率的预测"
]
},
...
]
}
当然,后续在生产环境中这套 Judge 也可以接入 CI 流水线。
Judge -> Agent 质量
链路诊断
| 评估维度 | 可能的根因 (Root Cause) | 针对性提升措施 (Action Items) | 优先级 |
|---|---|---|---|
| E2E 低 | 错误累积效应;严重幻觉;逻辑断裂 | 1. 回溯组件定位短板 2. 增加终稿前的Self-Correction步骤 3. 缩短Loop次数减少漂移 | 🔴 High |
| Plan低 / Query高 | Plan生成Prompt缺乏结构;指令遵循差 | 1. 加入CoT思维链示例 2. 强制结构化输出(JSON/MD) 3. 前置意图复述 | 🟠 Medium |
| Plan高 / Query低 | 子任务转关键词能力差;检索策略弱 | 1. 引入Query Optimizer (SEO思维) 2. 增加Site限定符 3. 尝试HyDE检索 | 🟠 Medium |
| Plan低 / Query低 | 基座模型能力弱;System Prompt混乱 | 1. 升级或更换基座模型 2. 精简System Prompt 3. 全链路Few-Shot训练 | 🔴 High |
| Summarisation低 | 幻觉;不忠实于原文;过度脑补 | 1. 强制引用(Grounding) 2. 提供Negative Examples 3. 分块压缩处理长文 | 🔴 High |
| Critique低 | "拍马屁"模式;缺乏批判性思维 | 1. 角色扮演严厉审稿人 2. 强制输出反对意见 3. Self-Consistency采样 | 🟡 Low |
| Plan Reflection低 | 缺乏元认知能力;无法识别信息缺口 | 1. 强化ReAct模式 (Thought-Action) 2. 强制Gap Analysis 3. 设置Early Stopping | 🟠 Medium |
| Alignment低 | 注意力漂移;上下文过长导致失焦 | 1. 近期偏差处理 (Recency Bias) 2. 映射校验 (Query->Plan) 3. 缩小生成上下文 | 🟠 Medium |
| Citation低 | 张冠李戴;编造URL;格式错误 | 1. URL黑名单校验 2. Chunk编号绑定 3. Post-Hoc链接可达性验证 | 🟡 Low |
质量管控
目标
关于质量管控,不管是以前传统的 iOS 开发或其他,还是当下的 Agent 开发,其核心目标和原则都是一致的。
- 核心目标:"事后救火" --> "事前预防+事中拦截+事后迭代"
- 核心原则:
-
评估前置:需求阶段就定义评估标准,避免上线后才发现不符合要求
-
增量验证:小步快跑,每次改动只验证相关模块,不阻塞研发效率
-
回归兜底:所有改动必须经过全量回归,避免"按下葫芦浮起瓢"
-
数据驱动:所有质量决策基于 Judge 评分和归因(以前是线上其他指标),拒绝拍脑袋
-
SOP
| 阶段 | 主要内容 | 发生周期 | Judge核心作用 | 准出标准 |
|---|---|---|---|---|
| 1. 迭代前 基线对齐 | 1. 更新test_eval.json测试集 2. 跑全量评估生成baseline.json 3. 对齐本次迭代的质量目标(如E2E≥85分) |
需求评审后 开发启动前 (一次性) | 标尺作用:验证测试集覆盖度,确认基线合理性,防止目标脱离实际。 | 1. 基线报告已存档 2. 测试集更新完毕 3. 质量目标全员对齐 |
| 2. 迭代中 增量验证 | 1. 代码提交前运行增量评估 2. 触发三层归因规则定位Bug 3. 本地修复并复测 | 开发过程中 每次提交代码前 (高频) | 显微镜作用:精准定位改动影响范围,区分是Plan问题、Query问题还是 Summarisation问题,避免全量评估耗时。 | 1. 改动相关维度分数 ≥ 基线95% 2. 无新增红线问题 3. Git Hook校验通过 |
| 3. 发布前 全量准入 | 1. CI/CD 流水线全量评估 2. 人工抽检 Top10 低分Case 3. 校验 Judge 打分一致性 | 提测后 上线前 (每次发版) | 门禁作用:作为质量守门员,拦截回归问题。只有全量通过才允许合并代码/上线。 | 1. 全量分数 ≥ 基线95% 2. E2E 达到迭代目标分 3. 无红线问题 ,黄线问题有预案 4. Judge与人工一致性≥90% |
| 4. 上线后 持续监控 | 1. 线上流量 10% 抽样评估 2. 监控核心指标(幻觉率、引用率) 3. 触发告警或紧急回滚 | 上线后 7天核心观察期 (持续) | 雷达作用:弥补离线测试盲区,发现长尾场景问题,提供线上真实质量的客观反馈。 | 1. 线上分数波动 ≤ 10% 2. 无突发质量故障 3. 核心指标符合预期 |
| 5. 复盘期 能力迭代 | 1. 复盘低分 Case 根因 2. 将 Bad Case 固化到测试集 3. 优化 Judge Prompt 与归因规则 | 双周/月度 复盘会议 (周期性) | 教练作用:驱动迭代方向。通过分析低分分布,指导 Prompt 优化、模型微调或架构调整。 | 1. 测试集覆盖所有已知缺陷 2. Judge 打分一致性 ≥ 90% 3. 共性问题已修复并有排期 |
质量红黄线
SOP 各阶段严格遵守,触碰红线直接打回,黄线需记录风险:
| 等级 | 触发条件 | 处置方式 |
|---|---|---|
| 🔴 红线 | 1. E2E幻觉率 > 2% 2. 引用编造率 > 1% 3. 核心事实缺失率 > 5% 4. 系统崩溃率 > 0.5% | 直接打回,禁止进入下一阶段,必须修复后才能重新提测 |
| 🟡 黄线 | 1. 单个维度分数较基线下降 > 10% 2. E2E 平均分较基线下降 > 5% 3. 非核心场景覆盖不足 | 记录风险,输出预案后可进入下一阶段,但需在后续迭代中优化 |