---
type: design
node_id: design:2026-07-07-search-module-design
title: "搜索 Agent 装配层设计（app/search/）"
date: 2026-07-07
---

# 搜索 Agent 装配层设计（app/search/）

**日期** 2026-07-07 · **状态** 已批准 · **关联** TRM4 `core/search/` + `core/tree/tools.py` + `core/tree/vision.py` + `core/tree/summarizer.py`

---

## §1 定位

`app/search/` 是搜索 Agent 的"装配层"——为 `core/agent/loop.py` AgentLoop 提供 **prompt 组装**、**skill 管理**、**工具定义/分发** 和 **视觉观察**。它不控制推理循环，只被 AgentLoop 调用；编排责任在 `app/harness/inference.py`。

### 与 TRM4 的映射

| TRM4 | TRM5 | 变更类型 |
|------|------|---------|
| `core/search/prompt.py` | `app/search/prompt.py` | 保真迁移 + P4 显式参数 |
| `core/search/skills.py` | `app/search/skills.py` | 保真迁移 |
| `core/tree/tools.py` | `app/search/tools.py` | 重组为 `SearchToolDispatcher` 类 |
| `core/tree/vision.py` | `app/search/vision.py` | 异步化 + Protocol 注入 |
| `core/tree/summarizer.py` | `app/search/summarizer.py` | 异步化 + Protocol 注入；含 anchor 锚模式 |
| `core/tree/ocr.py` | `adapters/ocr.py` | 异步化 + OCRProvider Protocol |

---

## §2 模块结构

```
app/search/
├── __init__.py       # 公开 API 重导出
├── prompt.py         # PromptManager — prompt 加载与拼装
├── skills.py         # SkillRegistry + discover_skills — skill 扫描与注册
├── tools.py          # SearchToolDispatcher（实现 ToolDispatcher Protocol）
├── summarizer.py     # question-conditioned 两轮 LLM 摘要（view_node / search_similar 用）
└── vision.py         # observe_frame（VLM 两轮 + OCR 注入）

adapters/
└── ocr.py            # MonkeyOCRClient（实现 OCRProvider Protocol）

app/ports.py          # 新增 OCRProvider Protocol（应用层端口，与 EmbeddingProvider 同级）

store/prompts/        # 初始种子，逐文件从 TRM4 store/prompts/v2/ 字节级复制，不修改
├── system.md                        # ← TRM4 store/prompts/v2/system.md
├── observe_frame_extract.md         # ← TRM4 store/prompts/v2/observe_frame_extract.md
├── observe_frame_verify.md          # ← TRM4 store/prompts/v2/observe_frame_verify.md
├── view_node_extract.md             # ← TRM4 store/prompts/v2/view_node_extract.md
├── view_node_verify.md              # ← TRM4 store/prompts/v2/view_node_verify.md
├── view_node_children_extract.md    # ← TRM4 store/prompts/v2/view_node_children_extract.md
├── view_node_children_verify.md     # ← TRM4 store/prompts/v2/view_node_children_verify.md
├── search_similar_extract.md        # ← TRM4 store/prompts/v2/search_similar_extract.md
└── search_similar_verify.md         # ← TRM4 store/prompts/v2/search_similar_verify.md
```

---

## §3 依赖方向

```mermaid
flowchart TB
    subgraph adapters
        OCR_IMPL["adapters/ocr.py\nMonkeyOCRClient"]
    end

    subgraph core
        PROTO["app/ports.py\nOCRProvider Protocol"]
        AGENT_PROTO["core/agent/protocols.py\nToolDispatcher Protocol"]
    end

    subgraph app/search
        PROMPT["prompt.py\nPromptManager"]
        SKILLS["skills.py\nSkillRegistry"]
        TOOLS["tools.py\nSearchToolDispatcher"]
        SUMM["summarizer.py\nsummarize_node / _children / _batch"]
        VISION["vision.py\nobserve_frame"]
    end

    subgraph app/tree
        ENV["environment.py\nTreeEnvironment"]
    end

    OCR_IMPL -->|实现| PROTO
    TOOLS -->|实现| AGENT_PROTO
    TOOLS --> ENV
    TOOLS --> SKILLS
    TOOLS --> VISION
    TOOLS --> SUMM
    SUMM -->|依赖| PROTO_LLM["core/protocols.py\nLLMProvider"]
    VISION -->|依赖| PROTO
    VISION -->|依赖| PROTO_VLM["core/protocols.py\nVLMProvider"]
    PROMPT --> SKILLS
    PROMPT -.->|读取| STORE["store/prompts/*.md"]
    SUMM -.->|读取| STORE
```

依赖只向内或同层，`core/` 不认识 `app/search/`。

---

## §4 公开 API

### 4.1 PromptManager（prompt.py）

```python
class PromptManager:
    def __init__(self, prompts_dir: Path) -> None: ...
    def build_inference_prompt(
        self,
        skill_mode: str,
        task_type: str,
        always_skills_text: str,
        task_skill_map: dict[str, str],
        catalog_text: str,
    ) -> str: ...
    def format_user_prompt(
        self,
        question: str,
        options: list[str],
        l1_node_ids: list[str],
        task_type: str | None = None,
    ) -> str: ...
    def load(self, name: str) -> str: ...
```

**与 TRM4 有意变更**：
- 工具描述从 `app/search/tools.py` 的 `get_tool_descriptions()` 获取（职责归属修正）
- `format_user_prompt` 参数从 `dict` 改为显式 `question` / `options` / `l1_node_ids`（P4）

### 4.2 SkillRegistry + discover_skills（skills.py）

```python
def parse_frontmatter(text: str) -> dict[str, str]: ...
def strip_frontmatter(text: str) -> str: ...

class SkillRegistry:
    def set_paths(self, mapping: dict[str, Path]) -> None: ...
    def read(self, name: str) -> str: ...

def discover_skills(skills_dir: Path) -> tuple[str, dict[str, str], str, SkillRegistry]: ...
```

与 TRM4 逻辑完全一致，无有意变更。

### 4.3 SearchToolDispatcher（tools.py）

```python
def get_tool_descriptions(include_read_skill: bool = False) -> str: ...

class SearchToolDispatcher:
    """实现 core/agent/protocols.ToolDispatcher。"""
    def __init__(
        self,
        env: TreeEnvironment,
        tool_llm: LLMProvider,
        vlm: VLMProvider,
        ocr: OCRProvider | None,
        prompts_dir: Path,
        skills: SkillRegistry | None,
        *,
        embed_fn: Callable[[str | list[str]], np.ndarray],
        verify_vision: bool,
        anchor: bool,
        assemble_mode: str,
        stats_sink: Callable[[dict[str, int]], None] | None = None,
    ) -> None: ...

    async def dispatch(
        self, tool_name: str, args: dict[str, Any], *, context: dict[str, Any]
    ) -> str: ...
```

| 工具 | 实现路径 |
|------|---------|
| `view_node` | → `env.get_node_text(node_id)` 获取原始文本 + `env.get_children_info(node_id)` 获取子节点结构化信息 → `summarizer.summarize_node(...)` 两轮 LLM 摘要 + `summarizer.summarize_children(...)` 子节点标注 |
| `search_similar` | → `env.search_similar(query, top_k, embed_fn=...)` 获取 `[(node_id, score)]` → 对每个 node_id 调 `env.get_node_text(node_id)` → `summarizer.summarize_nodes_batch(...)` 并发两轮 LLM 摘要 + 格式化 |
| `observe_frame` | → `env.resolve_frame_paths(...)` + `env.get_subtitle(node_ids[0])` 获取字幕 → `vision.observe_frame(...)` → 输出前拼接 `[字幕上下文]`（保真 TRM4 tools.py:136-153） |
| `submit_answer` | → 返回确认文本 |
| `read_skill` | → `skills.read(name)` |
| 未知工具 | → `raise ValueError`（AgentLoop 捕获，不计步） |

**与 TRM4 有意变更**：
- 自由函数 + 大量位置参数 → 类封装（构造时注入依赖）
- 工具描述 `get_tool_descriptions()` 移入此文件
- LLM 摘要从 environment 拆出到 `summarizer.py`（environment 回归纯数据层）
- `SearchToolDispatcher.__init__` 新增 `tool_llm: LLMProvider` 参数（工具级 LLM，thinking=False，用于 summarizer）
- `dispatch()` 从 `context` 中提取 `session_id` / `parent_call_id`，透传给 summarizer / vision 的 LLM/VLM 调用，确保遥测链路完整

### 4.4 summarizer（summarizer.py）

从 TRM4 `core/tree/summarizer.py` 迁移。三个工具（view_node / search_similar / observe_frame）共享同构的"提取→验证"两轮模式。summarizer 负责前两个工具的文本摘要，vision 负责第三个的视觉摘要。

```python
async def summarize_node(
    llm: LLMProvider,
    raw_text: str,
    question: str,
    prompts_dir: Path,
    *,
    anchor_map: dict[str, str] | None,
    assemble_mode: str,
    stats_sink: Callable | None = None,
    session_id: str | None = None,
    parent_call_id: str | None = None,
) -> str: ...

async def summarize_children(
    llm: LLMProvider,
    children_info: list[dict[str, Any]],
    question: str,
    prompts_dir: Path,
    *,
    session_id: str | None = None,
    parent_call_id: str | None = None,
) -> str: ...

async def summarize_nodes_batch(
    llm: LLMProvider,
    items: list[tuple[str, str, str]],
    question: str,
    prompts_dir: Path,
    *,
    session_id: str | None = None,
    parent_call_id: str | None = None,
) -> list[tuple[str, str]]: ...
```

| 函数 | Prompt 文件 | 输出格式 |
|------|-------------|---------|
| `summarize_node` | `view_node_extract.md` + `view_node_verify.md` | `"[内容摘要] ...\n[核实] ..."` |
| `summarize_children` | `view_node_children_extract.md` + `view_node_children_verify.md` | `"★★/★ 标注\n[核实] ..."` |
| `summarize_nodes_batch` | `search_similar_extract.md` + `search_similar_verify.md` | `[("node_id", "[内容摘要] ..."), ...]` |

**anchor 锚模式**（`check_anchors` / `assemble_anchored_output`）保真迁移：给原始文本每行编号（`[c1]` `[s1]`），LLM 摘要引用行号，代码端校验合法性并展开引文。当前生产 `anchor=False`，但代码路径完整保留供后续 A/B 实验。

**与 TRM4 有意变更**：

| 项目 | TRM4 | TRM5 |
|------|------|------|
| 归属 | `core/tree/summarizer.py`（嵌入 environment） | `app/search/summarizer.py`（独立模块） |
| 异步 | `_call_llm` 同步 | `await llm.chat()` |
| LLM 接口 | 裸 LLMClient | LLMProvider Protocol |
| 并发 | `ThreadPoolExecutor` | `asyncio.gather`（搜索结果批量摘要） |
| Prompt 内容 | store/prompts/v2/ | 原封不动复制 |

### 4.5 observe_frame（vision.py）
（原 §4.4，编号因插入 summarizer 顺移）

```python
async def observe_frame(
    vlm: VLMProvider,
    frame_paths: list[Path],
    question: str,
    prompts_dir: Path,
    *,
    ocr: OCRProvider | None,
    verify: bool,
    stats_sink: Callable[[dict[str, int]], None] | None = None,
    session_id: str | None = None,
    parent_call_id: str | None = None,
) -> str: ...
```

两轮 VLM 调用保真：

```
1. [可选] OCR 转录 → 事前并置到 user_content
2. 提取轮: VLM + observe_frame_extract.md
3. [可选] 验证轮: VLM + observe_frame_verify.md
4. 返回 "[视觉观察] {证据}\n[验证] {核实结果}"
```

**与 TRM4 有意变更**：

| 项目 | TRM4 | TRM5 |
|------|------|------|
| 异步 | `_call_vl` 同步 | `await vlm.chat_with_images()` |
| VLM 接口 | 裸 LLMClient + 手动 base64 | VLMProvider Protocol，images 传 Path |
| OCR 接口 | `Callable[[list[Path]], str]` | `OCRProvider` Protocol（async） |
| Prompt 内容 | store/prompts/v2/ | 原封不动复制 |

### 4.6 OCRProvider Protocol（app/ports.py 新增）

```python
@runtime_checkable
class OCRProvider(Protocol):
    """帧文字转录端口。"""
    async def transcribe_frames(self, frame_paths: list[Path]) -> str: ...
```

放置在 `app/ports.py`（与 `EmbeddingProvider` 同级），而非 `core/protocols.py`——OCR 只被 `app/search/` 使用，不是 core 共享端口。

### 4.7 MonkeyOCRClient（adapters/ocr.py）

```python
class MonkeyOCRClient:
    """实现 OCRProvider Protocol。多端点轮询 + 单帧降级。"""
    def __init__(self, urls: list[str]) -> None: ...
    async def check_health(self) -> None: ...
    async def transcribe_frames(self, frame_paths: list[Path]) -> str: ...
```

内部同步 HTTP 调用通过 `asyncio.to_thread` 包装。端点轮询 + 线程安全 Session 保留 TRM4 逻辑。

### 4.8 TreeEnvironment 新增 API（app/tree/environment.py 扩展）

现有 `view_node()` 返回格式化字符串，不适合 summarizer 消费。需新增结构化查询方法：

```python
def get_node_text(self, node_id: str, *, anchor: bool = False) -> tuple[str, dict[str, str] | None]:
    """返回节点原始文本（或带行号锚的文本）+ anchor_map。"""
    ...

def get_children_info(self, node_id: str) -> list[dict[str, Any]]:
    """返回子节点结构化信息列表 [{id, time_range, summary}, ...]。"""
    ...
```

现有 `view_node()` 和 `search_similar()` 保持不变（向后兼容），新方法专供 `SearchToolDispatcher` 使用。

---

## §5 交互流程

```mermaid
sequenceDiagram
    participant H as harness/inference
    participant PM as PromptManager
    participant SK as discover_skills
    participant AL as AgentLoop
    participant TD as SearchToolDispatcher
    participant ENV as TreeEnvironment
    participant S as summarizer
    participant V as vision.observe_frame
    participant LLM as LLMProvider(tool)
    participant VLM as VLMProvider
    participant OCR as OCRProvider

    H->>SK: discover_skills(skills_dir)
    SK-->>H: (always_text, task_skill_map, catalog_text, registry)
    H->>PM: build_inference_prompt(...)
    PM-->>H: system_prompt
    H->>PM: format_user_prompt(question, options, l1_ids)
    PM-->>H: user_prompt
    H->>TD: 构造(env, tool_llm, vlm, ocr, prompts_dir, registry, embed_fn)
    H->>AL: run(system_prompt, user_prompt, tool_dispatcher)

    loop AgentLoop 每步推理
        AL->>TD: dispatch("view_node", {node_id, question}, context)
        TD->>ENV: view_node(node_id)
        ENV-->>TD: 原始 card 文本 + 子节点列表
        TD->>S: summarize_node(llm, raw_text, question, ...)
        S->>LLM: extract 轮
        LLM-->>S: raw_summary
        S->>LLM: verify 轮
        LLM-->>S: verify_result
        S-->>TD: "[内容摘要] ...\n[核实] ..."
        TD->>S: summarize_children(llm, children_info, question, ...)
        S-->>TD: "★★/★ 标注\n[核实] ..."
        TD-->>AL: 完整输出

        AL->>TD: dispatch("search_similar", {query, question, k}, context)
        TD->>ENV: search_similar(query, top_k, embed_fn)
        ENV-->>TD: [(node_id, score), ...]
        TD->>S: summarize_nodes_batch(llm, items, question, ...)
        S-->>TD: 并发两轮摘要结果
        TD-->>AL: 格式化输出

        AL->>TD: dispatch("observe_frame", {node_ids, question}, context)
        TD->>ENV: resolve_frame_paths(node_ids)
        ENV-->>TD: list[Path]
        TD->>V: observe_frame(vlm, paths, question, ...)
        V->>OCR: transcribe_frames(paths)
        OCR-->>V: ocr_text
        V->>VLM: extract 轮（图片+OCR+问题）
        VLM-->>V: raw_evidence
        V->>VLM: verify 轮（图片+证据）
        VLM-->>V: verify_result
        V-->>TD: "[视觉观察] ...\n[验证] ..."
        TD-->>AL: 输出

        AL->>TD: dispatch("submit_answer", args, context)
        TD-->>AL: "[ok] 答案已提交"
    end
    AL-->>H: LoopResult
```

---

## §6 错误处理

| 场景 | 处理 | 与 TRM4 一致性 |
|------|------|---------------|
| 节点不存在 | env 抛 KeyError，dispatcher 捕获返回错误文本 | 一致 |
| summarize_node 提取轮失败 | 捕获 Exception，返回 `[摘要错误]` | 一致 |
| summarize_node 验证轮失败 | 降级返回 `[核实] 跳过（调用失败）` | 一致 |
| summarize_children 提取轮失败 | 降级回退原始子节点列表 | 一致 |
| 帧文件不存在 | FileNotFoundError，vision 返回 `[VL错误]` | 一致 |
| VLM 提取轮失败 | 捕获 Exception，返回 `[VL错误]` | 一致 |
| VLM 验证轮失败 | 降级返回 `[验证] 跳过（调用失败）` | 一致 |
| OCR 失败 | 降级不注入，stats `ocr_failed=1` | 一致 |
| 未知工具名 | raise ValueError，AgentLoop 不计步 | 一致 |
| read_skill 未注册 | KeyError 透传，dispatcher 捕获返回错误文本 | 一致 |

**原则**：工具执行错误不中断 AgentLoop。未知工具名 `raise ValueError`（由 AgentLoop 捕获不计步）；已知工具的运行时错误在 dispatcher 层转为错误文本返回。

**允许的降级边界**（刻意宽泛捕获，与 TRM4 一致）：
- OCR 转录失败 → 降级不注入（`ocr_fn` 是外部依赖，任何异常不得中断工具主流程）
- VLM 验证轮失败 → 降级跳过验证（提取结果仍然有效）
- summarize_children 失败 → 回退原始子节点列表

其他异常（如节点不存在、帧文件缺失）捕获特定异常类型，不做宽泛降级。

---

## §7 测试策略

| 测试文件 | 覆盖 | 方法 |
|----------|------|------|
| `tests/unit/test_search_prompt.py` | PromptManager 加载/拼装/格式化 | 临时目录写真实 TRM4 v2 prompt 文件 |
| `tests/unit/test_search_skills.py` | frontmatter 解析、discover_skills 分类 | 临时目录写 .md |
| `tests/unit/test_search_tools.py` | SearchToolDispatcher 5 个工具分发 + 摘要集成 | 假 env/LLM/VLM/OCR 通过 Protocol 注入 |
| `tests/unit/test_search_summarizer.py` | summarize_node（含 anchor 模式）、summarize_children、summarize_nodes_batch；check_anchors / assemble 纯函数用真实输入 | 假 LLMProvider |
| `tests/unit/test_search_vision.py` | observe_frame 两轮、OCR 注入/降级、stats、字幕拼接 | 假 VLMProvider + 假 OCRProvider |
| `tests/unit/test_ocr_adapter.py` | MonkeyOCRClient 健康检查/轮询/降级 | `responses` 库 mock HTTP |

---

## §8 被否决的方案

| 方案 | 否决理由 |
|------|---------|
| vision.py 放 app/tree/ | observe_frame 是搜索工具实现，不是建树管线；tree/ 是离线预处理模块 |
| tools/ 子包 | 当前仅 5 个工具，子包过度组织 |
