DeepResearchSystem 0x06:LLM as Judge

0 阅读31分钟

回顾 -> 新问题

  1. DeepRearchSystem 0x00:初识
  2. DeepRearchSystem 0x01:Agent 基础
  3. DeepRearchSystem 0x02:Graph 构建
  4. DeepRearchSystem 0x03:HITL
  5. DeepResearchSystem 0x04:MAS 进阶

至此,DeepResearchSystem 已经是一个多 Agent 协作的智能体研报系统。但是到此就结束了吗?用我们之前客户端的思维,一个产品从开发完成到上线之前往往还需要经历严格的测试,以保证产品可用性和性能。那么对于一个 Agent?如何确保其可用性呢?怎么去对一个 Agent 做测试?

解决

Agent 测试困境

回想一下以往我们客户端的测试方式:

  • 分析业务场景可能对应的 case,创建测试用例
  • 测试时拿程序执行结果去和测试用例的结果对比

但是对于 LLM Agent,输出并不是确定的。所以我们就不能靠以前传统的那套测试方式来应对 Agent。同时 Agent 的测试困境还不限于此:

  1. 输出非确定性:同个输入跑 N 次输出都不一样,没法用传统等值断言;

  2. 中间态不可观测:Agent 多步推理的中间结果(比如原始搜索内容、临时计划)默认不暴露,出问题没法定位;

  3. 因果链难追溯:最终报告出错,可能是计划、搜索、摘要任意环节的问题,定位成本可能是传统测试的10倍以上;

  4. 幻觉难界定:传统规则没法判断“无中生有”的语义幻觉,比如 Agent 编了不存在的政策条款;

  5. 环境依赖强:依赖搜索、工具调用等外部环境,测试结果波动大,反复调用外部API成本极高

困境突围

既然有这么多不确定性,显然不能像传统测试有一个比较确定的预期结果和测试用例。针对以上困境,业界比较常规的做法有:

  1. 端到端评估:测整体任务完成度,用 LLM 去评估一次执行结果的完整性、准确性、信息覆盖度等

  2. 组件化评估:将 Agent 流程拆分成组件去评估,用解耦的思路去单独测试评估每个组件。比如,规划单独可测,搜索单独可测

  3. 混合策略:确定性规则(比如引用 URL 是否存在、API调用是否成功)用传统测试,语义判断(比如内容相关性、逻辑合理性)用 LLM 去评估

  4. 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)对最终报告的以下指标进行打分:
      • 事实准确性
      • 覆盖度
      • 逻辑性
      • 时效性
      • 引用质量
  • 组件级

    • 利用HookContextWebSearchAgent.step方法进行Monkey-Patch(猴子补丁) ,无侵入地捕获中间态数据(如原始搜索结果 vs AI生成的摘要)。
      • **Monkey-Patch **:本质上就是 hook,和我们 iOS 里的 “黑魔法”——方法交换同理
    • 对Agent的每一个关键节点进行独立评分:
      • 计划生成
      • 查询构建
      • 摘要总结
      • 反思批判
      • 引用标注
      • 计划反思
  • 模拟Human-in-the-loop

    • 通过TopicCfg中 Mock 的user_feedbackexpected_intent,模拟用户对研究计划的反馈,专门评估 Agent 的
      • 意图识别能力
      • Replanning(重规划)质量

其他技术细节

  1. Prompt-模型-输出的强绑定:每个评估维度都有独立的 Propmt 模板,每个 Propmt 模板对应各自的 Pydantic 评分模型,映射关系严格
    • Prompt 定义评估规则、维度、输出格式;
    • Judger 调用LLM生成输出;
    • Pydantic模型强制校验输出结构,自动转换数据类型(比如把字符串分数转成float)
  2. LLM 输出治理。不直接使用json.loads解析,使用自定义的解析方式,用于去除无关文本,减少 Token 浪费。比如输出可能包含 "好的,以下是评估结果:".
  3. 异常隔离:单个 topic 运行失败只会记录E2EResult不会中断整个批次评估
  4. 环境一致性。全链路复用业务Agent的调用逻辑,保证评估环境和真实运行环境完全一致
  5. Monkey-Patch 机制,保障业务代码零侵入的前提下捕获中间态来进行测试
  6. 业务回调解耦
    • WebSearchCapture 是纯业务类,只负责存储原始搜索结果
    • post_hook_handler 严格遵守 HookContext 回调契约,不阻断原方法返回值
  7. 中间态解耦映射,原始搜索raw_results和Agent生成的摘要phase2_state["web_search_result"] 一一对应
  8. 双模独立run_e2erun_components运行完全解耦,可独立评估。
  9. 结果双写
    • format_eval_report:生成人类可读的文本摘要,包含平均分、各维度得分、典型错误,方便研发快速定位问题
    • save_eval_report:保存全量JSON数据,包含所有原始中间态、评分细节、逐条归因信息,方便后续做数据分析(比如统计幻觉的分布、引用错误的常见原因、不同topic的难度系数)

整体架构图

综上,我们设计的评估系统就是一套面对 DeepResearchSystem 的 「可观测-可量化-可归因」评估框架,核心架构分为四层,每层完全解耦,从底到顶分别是:

┌─────────────────────────┐
│      编排调度层         │  ← evaluator.py:批量调度、异常隔离、报告生成
├─────────────────────────┤
│      评估执行层         │  ← evaluator.py:端到端/组件级评估逻辑、中间态关联
├─────────────────────────┤
│      评分标准化层       │  ← judger.py + judge_prompts.py:LLM-as-Judge调用、结构化输出约束
├─────────────────────────┤
│      数据采集层         │  ← hook_context.py + WebSearchCapture:无侵入中间态捕获
└─────────────────────────┘

工作流程图

评估模块的执行流程为:

image.png

Coding

废话少说上代码。

evaluator(编排)

TopicCfg(评估主题配置)

  • topic:研究主题。Agent 的入口,也是 judge 的核心
  • initial_search_query_count:搜索主题数量
  • max_research_loops:调研的最大循环次数
  • user_feedback:用户反馈。用于模拟 HITL
  • expected_intent:预期内容。用于模拟 HITL
@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

初始化

# ---------- 评估编排器 ----------
class Evaluator:
    """
    评估编排器
    """
    def __init__(self, judge_model_id: str | None = None):
        self.judger = Judger(model_id=judge_model_id)

E2E 端到端

数据类
@dataclass
class E2EResult:
    """
    端到端评估结果
    """
    topic: str
    report: str = ""
    sources: str = ""  # JSON 序列化的 sources_gathered
    score: E2EScore | None = None
    error: str | None = None
执行函数
# --- 端到端 ---
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框架
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。

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来无侵入地捕获数据。

# 1. 实例化业务捕获器
capture = WebSearchCapture()

# 2. 调用 Agent,注入钩子
agent_result = self._invoke_search_agent_feedback(
    cfg,
    capture_hooks=[capture]  # 注入插座
)

Component 组件级

数据类
@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
执行函数
# --- 组件级 ---
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 执行过程获取数据,真正从了六大维度评估打分。

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:会看录像,按标准打分
[_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

实现代码如下:

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,
    }

评估结果

数据类
@dataclass
class EvalReport:
    """
    评估报告
    """
    timestamp: str
    e2e_results: list[E2EResult] = field(default_factory=list)
    component_results: list[ComponentResult] = field(default_factory=list)
报告格式化
# ---------- 报告格式化 ----------
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)
报告保存
# 保存报告
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}")

评估执行

数据类

这里才是真正评估的类,负责根据指标进行打分。所以先需要定义评分的数据结构。

# ---------- 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

核心函数

# ---------- 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 评估

# -- 端到端 --
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 模板和返回的数据结构。这里以 计划评估为例。其他几个维度类似。

# -- 组件级评估 --
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 撰写。分别以两个维度做个示例吧。

端到端
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- 计划-查询对齐性
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 的角色是:

image.png

Judge CLI 的核心流程:

image.png

Codding

初始化

parser = argparse.ArgumentParser(
    description="DeepResearchSystem Judge CLI",
    formatter_class=argparse.RawDescriptionHelpFormatter,
)

modde

parser.add_argument(
    "--mode",
    choices=["e2e", "comp", "all"],
    default="e2e",
    help="评估模式:e2e(端到端)、comp(组件级)、all(两者都运行)",
)

model

parser.add_argument(
    "--judge-model",
    type=str,
    default=None,
    help="Judge LLM 的模型 ID(默认使用环境变量 EVAL_MODEL 或最后一个可用模型)",
)

topic

parser.add_argument(
    "--topic",
    type=str,
    default=None,
    help="要评估的单个研究主题(覆盖测试集)",
)

其他参数

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 -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

测试用例

{
  "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. 非核心场景覆盖不足
记录风险,输出预案后可进入下一阶段,但需在后续迭代中优化