自审发现 TRM4 三个搜索工具全有 question-conditioned 中间摘要层, 原设计遗漏了 summarizer.py。新增: - §4.4 summarizer API(summarize_node / _children / _nodes_batch) - anchor 锚模式完整保留 - 6 个额外 prompt 文件 - 更新交互流程图、错误处理、测试策略
14 KiB
type, node_id, title, date
| type | node_id | title | date |
|---|---|---|---|
| design | design:2026-07-07-search-module-design | 搜索 Agent 装配层设计(app/search/) | 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)
core/protocols.py # 新增 OCRProvider Protocol
store/prompts/ # 初始种子(从 TRM4 v2 直接复制,不修改)
├── system.md
├── observe_frame_extract.md
├── observe_frame_verify.md
├── view_node_extract.md
├── view_node_verify.md
├── view_node_children_extract.md
├── view_node_children_verify.md
├── search_similar_extract.md
└── search_similar_verify.md
§3 依赖方向
flowchart TB
subgraph adapters
OCR_IMPL["adapters/ocr.py\nMonkeyOCRClient"]
end
subgraph core
PROTO["core/protocols.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
PROMPT --> SKILLS
PROMPT -.->|读取| STORE["store/prompts/*.md"]
SUMM -.->|读取| STORE
依赖只向内或同层,core/ 不认识 app/search/。
§4 公开 API
4.1 PromptManager(prompt.py)
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)
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)
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 = True,
anchor: bool = False,
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.view_node(node_id) 获取原始文本 → summarizer.summarize_node(...) 两轮 LLM 摘要 + summarizer.summarize_children(...) 子节点标注 |
search_similar |
→ env.search_similar(query, top_k, embed_fn=...) → summarizer.summarize_nodes_batch(...) 并发两轮 LLM 摘要 + 格式化 |
observe_frame |
→ env.resolve_frame_paths(...) + vision.observe_frame(...) |
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)
4.4 summarizer(summarizer.py)
从 TRM4 core/tree/summarizer.py 迁移。三个工具(view_node / search_similar / observe_frame)共享同构的"提取→验证"两轮模式。summarizer 负责前两个工具的文本摘要,vision 负责第三个的视觉摘要。
async def summarize_node(
llm: LLMProvider,
raw_text: str,
question: str,
prompts_dir: Path,
*,
anchor_map: dict[str, str] | None = None,
assemble_mode: str = "ids_expand",
stats_sink: Callable | None = None,
) -> str: ...
async def summarize_children(
llm: LLMProvider,
children_info: list[dict[str, Any]],
question: str,
prompts_dir: Path,
) -> str: ...
async def summarize_nodes_batch(
llm: LLMProvider,
items: list[tuple[str, str, str]],
question: str,
prompts_dir: Path,
) -> 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 顺移)
async def observe_frame(
vlm: VLMProvider,
frame_paths: list[Path],
question: str,
prompts_dir: Path,
*,
ocr: OCRProvider | None,
stats_sink: Callable[[dict[str, int]], None] | None = None,
verify: bool = True,
) -> 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(core/protocols.py 新增)
@runtime_checkable
class OCRProvider(Protocol):
"""帧文字转录端口。"""
async def transcribe_frames(self, frame_paths: list[Path]) -> str: ...
4.7 MonkeyOCRClient(adapters/ocr.py)
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 逻辑。
§5 交互流程
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,所有异常在 dispatcher 层兜底为错误文本。
§7 测试策略
| ���试文件 | 覆盖 | 方法 |
|---|---|---|
test_search_prompt.py |
PromptManager 加载/拼装/格式化 | 临时目录写 prompt 文件 |
test_search_skills.py |
frontmatter 解析、discover_skills 分类 | 临时目录写 .md |
test_search_tools.py |
SearchToolDispatcher 5 个工具分发 + 摘要集成 | 假 env/LLM/VLM/OCR 通过 Protocol 注入 |
test_search_summarizer.py |
summarize_node(含 anchor 模式)、summarize_children、summarize_nodes_batch | 假 LLMProvider;check_anchors / assemble 用真实输入 |
test_search_vision.py |
observe_frame 两轮、OCR 注入/降级、stats | 假 VLMProvider + 假 OCRProvider |
test_ocr_adapter.py |
MonkeyOCRClient 健康检查/轮询/降级 | responses 库 mock HTTP |
§8 被否决的方案
| 方案 | 否决理由 |
|---|---|
| vision.py 放 app/tree/ | observe_frame 是搜索工具实现,不是建树管线;tree/ 是离线预处理模块 |
| tools/ 子包 | 当前仅 5 个工具,子包过度组织 |