--- 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 个工具,子包过度组织 |