Files
iomgaa 6bdb802f01 chore: track claude skills, tools, templates, reference code and research-wiki
- Add all claude skills (brainstorming, commit, debugging, TDD, etc.)
- Add claude hooks (pre-commit-guard, post-edit-quality)
- Add research templates (experiment plan, research brief, etc.)
- Add claude tools (arxiv/semantic_scholar/openalex fetch, wiki, exa)
- Add TRM4 reference implementation as algorithm fidelity baseline
- Add research-wiki content (plans, index, graph, query_pack)
- Update .gitignore to exclude .graphify_version runtime state
2026-07-06 20:59:03 -04:00

1415 lines
52 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 技术方案(TD)— Video-Tree-TRM
## 技术决策
- 单机本地执行,无服务端/数据库,所有数据 pickle/JSON 序列化到本地文件。
- 节点选择使用 **Cross-Attention**(学习 W_q/W_k/W_v/W_o 投影),替代简单 cosine 路由,更强表达力。
- L_level 推理模块使用 **MLP-based**RMSNorm + SwiGLU),因操作对象为单向量 `[B, D]`,非序列,无需 self-attention。
- 三个可学习组件(CrossAttentionSelector, ReasoningModule, q_head**跨层级共享权重**,与 TRM 原设计一致。
- 文本嵌入器(text_embed**冻结不训练**TreeIndex 中所有 embedding 为预计算静态值。
- 训练分两阶段:Phase 1 纯导航监督(单轮),Phase 2 加入 ACT halt(多轮)。
- MVP 优先文本模态(LongBench),视频模态(VideoMME)后续扩展。
- 配置管理:dataclass(无默认值,纯类型定义)+ YAML(全量配置)+ .env(敏感信息),优先级 CLI args > .env > YAML,三者统一归口到 dataclass。
---
## 目录
- [技术决策](#技术决策)
- [模块设计](#1-模块设计)
- [tree_index.py](#11-tree_indexpy--统一数据结构)
- [embeddings.py](#12-embeddingspy--嵌入服务)
- [llm_client.py](#13-llm_clientpy--llmvlm-客户端)
- [text_tree_builder.py](#14-text_tree_builderpy--文本树构建)
- [video_tree_builder.py](#15-video_tree_builderpy--视频树构建)
- [recursive_retriever.py](#16-recursive_retrieverpy--trm-递归检索器)
- [losses.py](#17-lossespy--损失函数)
- [answer_generator.py](#18-answer_generatorpy--答案生成)
- [pipeline.py](#19-pipelinepy--端到端管线)
- [config.py](#110-configpy--配置管理)
- [训练管线](#2-训练管线)
- [实验计划](#3-实验计划)
- [文件结构与依赖](#4-文件结构与依赖)
---
## 1. 模块设计
### 1.1 tree_index.py — 统一数据结构
**文件**: `video_tree_trm/tree_index.py`
**职责**: 定义三层树索引的节点类型、序列化/反序列化、嵌入矩阵提取。
> **延迟 Embedding 设计**build 阶段所有节点 `embedding=None``IndexMeta.embed_model/embed_dim` 也为 None。首次检索前由 `Pipeline._embed_tree()` 调用 `embed_all()` 统一填充。
```python
@dataclass
class IndexMeta:
source_path: str # 原始文件路径
modality: str # "text" | "video"
embed_model: Optional[str] = None # build 时为 Noneembed_all 后填充
embed_dim: Optional[int] = None # build 时为 Noneembed_all 后填充
created_at: str # ISO 时间戳(自动生成)
@dataclass
class L3Node:
id: str
description: str # 视频=VLM帧描述, 文本=原始段落
embedding: Optional[ndarray] # [D]build 时为 Noneembed_all 后填充
raw_content: Optional[str] # 原始文本(文本模式)
frame_path: Optional[str] # 帧图像路径(视频模式)
timestamp: Optional[float] # 帧时间戳(视频模式)
@dataclass
class L2Node:
id: str
description: str # 1-2句片段描述
embedding: Optional[ndarray] # [D]build 时为 None
time_range: Optional[Tuple[float, float]]
children: List[L3Node]
@dataclass
class L1Node:
id: str
summary: str # 2-3句聚合摘要
embedding: Optional[ndarray] # [D]build 时为 None
time_range: Optional[Tuple[float, float]]
children: List[L2Node]
@dataclass
class TreeIndex:
metadata: IndexMeta
roots: List[L1Node]
```
**关键方法**:
```python
class TreeIndex:
@property
def is_embedded(self) -> bool:
"""所有 L1/L2/L3 节点的 embedding 均非 None 时返回 True"""
def embed_all(
self,
embed_fn: Callable[[Union[str, List[str]]], ndarray],
model_name: str,
embed_dim: int,
) -> None:
"""批量 embed 所有节点,更新 metadata。
- L3 按 L2 分组批量调用(减少 API 调用次数)
- L1/L2 各单独 embed
- 仅对 embedding=None 的节点执行(支持增量更新)"""
def l1_embeddings(self) -> ndarray:
"""返回所有 L1 嵌入矩阵 [N1, D](需先 embed_all"""
def l2_embeddings_of(self, l1_idx: int) -> ndarray:
"""返回指定 L1 下所有 L2 子节点嵌入 [N2, D]"""
def l3_embeddings_of(self, l1_idx: int, l2_idx: int) -> ndarray:
"""返回指定 L2 下所有 L3 子节点嵌入 [N3, D]"""
def get_node(self, l1: int, l2: int, l3: int) -> L3Node:
"""按路径索引获取 L3 节点"""
# JSON 序列化(主格式,无 embedding,适合缓存和人工查看)
def save_json(self, path: str) -> None:
"""序列化为 JSON 文件(不含 embedding 向量)"""
@classmethod
def load_json(cls, path: str) -> "TreeIndex":
"""从 JSON 文件加载(embedding=None,需后续 embed_all"""
# pickle 序列化(向后兼容,含 embedding)
def save(self, path: str) -> None:
"""pickle 序列化(含 embedding 向量)"""
@classmethod
def load(cls, path: str) -> "TreeIndex":
"""从 pickle 文件加载"""
```
**依赖**: numpy, pickle, json(标准库)
---
### 1.2 embeddings.py — 嵌入服务
**文件**: `video_tree_trm/embeddings.py`
**职责**: 封装文本嵌入器,支持本地 sentence-transformers 和远程 OpenAI 兼容 API 双后端,冻结不训练。
```python
class EmbeddingModel:
"""文本嵌入器封装(冻结),支持本地/远程双后端。"""
def __init__(self, config: EmbedConfig):
"""
根据 config.backend 初始化:
- "local": 加载 sentence-transformers 模型,冻结参数
- "remote": 初始化 OpenAI 兼容 API 客户端
"""
@property
def dim(self) -> int:
"""嵌入维度 D"""
def embed(self, texts: Union[str, List[str]]) -> ndarray:
"""
文本 → 嵌入向量 (L2 归一化)
Args:
texts: 单条或批量文本
Returns:
[N, D] ndarray(单条时 N=1,每行 L2 范数为 1.0)
"""
def embed_tensor(self, texts: Union[str, List[str]]) -> Tensor:
"""同 embed(),返回 torch.Tensor [N, D]float32"""
# 内部方法
def _embed_local(self, texts: List[str]) -> ndarray:
"""sentence-transformers 本地推理,torch.no_grad() + normalize_embeddings=True"""
def _embed_remote(self, texts: List[str]) -> ndarray:
"""OpenAI 兼容 API: client.embeddings.create() → 提取向量 → L2 归一化"""
```
**远程模式示例** (GPUStack qwen3-embedding):
```python
# .env
EMBED_API_KEY=sk-xxx
EMBED_API_URL=http://gpu-host:8080/v1
# config/default.yaml
embed:
backend: "remote"
model_name: "qwen3-embedding-4b"
embed_dim: 2048
device: "cpu" # 远程模式不使用
api_key: "" # 从 .env 覆盖
api_url: "" # 从 .env 覆盖
```
**依赖**: sentence-transformers(本地模式), openai SDK(远程模式), torch, numpy
---
### 1.3 llm_client.py — LLM/VLM 客户端
**文件**: `video_tree_trm/llm_client.py`
**职责**: 统一封装 LLM(纯文本)和 VLM(多模态)API 调用,仅支持 OpenAI-compatible 单一接口,通过配置 `api_url` + `model` 切换服务商(Qwen DashScope、OpenAI、本地推理服务等)。
```python
class LLMClient:
"""OpenAI-compatible LLM/VLM 统一客户端。"""
def __init__(self, config: Union[LLMConfig, VLMConfig]) -> None:
"""
初始化客户端。
Args:
config: LLMConfig 或 VLMConfig,含 api_key、api_url、model 等参数。
Raises:
ValueError: api_key 或 api_url 为空时抛出。
实现:
openai.OpenAI(api_key=config.api_key, base_url=config.api_url)
"""
def chat(self, prompt: str, max_tokens: Optional[int] = None) -> str:
"""纯文本单轮对话,返回生成文本。max_tokens=None 时取 config.max_tokens。"""
def chat_with_images(
self, prompt: str, images: List[str], max_tokens: Optional[int] = None
) -> str:
"""
多模态对话(VLM)。
Args:
prompt: 文本指令。
images: 图像列表,每项为本地文件路径或已编码的 data URI 字符串。
Returns:
生成文本。
实现:
本地路径 → _encode_image() 转 base64 → _build_messages() 拼 content → API 调用。
"""
def batch_chat(self, prompts: List[str], max_tokens: Optional[int] = None) -> List[str]:
"""ThreadPoolExecutor(max_workers=8) 并发调用 chat(),保序返回。"""
# ── 私有辅助 ──
def _encode_image(self, path_or_b64: str) -> str:
"""
本地路径 → "data:image/{jpeg|png};base64,<data>"。
已含 "base64," 标记则直接返回(不重复编码)。
"""
def _build_messages(
self, prompt: str, images: Optional[List[str]] = None
) -> List[Dict]:
"""
无图像: [{"role": "user", "content": prompt}]
有图像: content 为列表,image_url 项在前,text 项在后。
"""
```
**消息结构(OpenAI-compatible**:
```python
# 纯文本
[{"role": "user", "content": prompt}]
# 多模态(图在 text 之前)
[{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}},
{"type": "text", "text": prompt}
]}]
```
**502/503 自动重试机制**:
```python
# 模块级辅助函数
def _call_with_retry(fn, label: str):
"""对 API 调用执行指数退避重试(仅重试 502/503)。
- 最多重试 20 次(约等待 20+ 分钟)
- 首次等待 60s,每次翻倍,上限 300s
"""
wait = 60
for attempt in range(1, 21):
try:
return fn()
except openai.InternalServerError as exc:
if exc.status_code not in {502, 503}:
raise
time.sleep(wait)
wait = min(wait * 2, 300)
```
**代理绕过**:
```python
self._client = openai.OpenAI(
api_key=config.api_key,
base_url=config.api_url,
http_client=httpx.Client(proxy=None), # 显式绕过系统代理,直连内网地址
)
```
**与 TD 原设计的差异**:
| 项目 | 原设计 | 实际实现 |
|------|--------|---------|
| 构造器签名 | `(backend, api_key, model, **kwargs)` | `(config: Union[LLMConfig, VLMConfig])` |
| 后端区分 | `"qwen" \| "openai" \| "ollama"` 分支 | 统一走 OpenAI-compatible,无后端分支 |
| `max_tokens` 默认值 | 函数参数硬编码 `= 256` | `= None`None 时取 `config.max_tokens` |
| `batch_chat` 并发 | "并发或顺序" | `ThreadPoolExecutor(max_workers=8)` |
| **重试** | 无 | `_call_with_retry()` 502/503 指数退避重试(新增) |
| **代理** | 无 | `httpx.Client(proxy=None)` 绕过系统代理(新增) |
**依赖**: openai SDK(≥1.0, httpx, python-dotenv(间接,via config
---
### 1.4 text_tree_builder.py — 文本树构建
> **状态**: ✅ 已实现 | 测试: `tests/unit/test_text_tree_builder.py`43 个用例全部通过)
**文件**: `video_tree_trm/text_tree_builder.py`
**职责**: 长文本 → TreeIndex,实现 L2 轴心构建策略。
> **注意**: 构造器不接受 `EmbeddingModel`(延迟 embedding 设计)。所有节点 `embedding=None`,由 `Pipeline.embed_all()` 在检索前统一填充。
#### 公共接口
```python
class TextTreeBuilder:
"""文本模态树构建器"""
def __init__(self, llm: LLMClient, config: TreeConfig):
self.llm = llm # LLM 客户端
self.config = config # TreeConfig(关键字段: max_paragraphs_per_l2
# ⚠ 无 embed_modelembedding 延迟到 Pipeline.embed_all()
def build(self, text: str, source_path: str) -> TreeIndex:
"""
完整构建流程:
Phase 1: _segment_text() → sections: List[List[str]]
Phase 2: llm.batch_chat() → 所有 L2 摘要并发生成(一次调用)
Phase 3: 逐层组装 L3 → L2 → L1 节点
Phase 5: 组装 TreeIndex + 写日志
"""
```
#### 内部方法
```python
def _segment_text(self, text: str) -> List[List[str]]:
"""调度: _detect_toc() → True → _segment_with_regex()
→ False → _segment_with_llm()
返回: sections[i] = [para_1, para_2, ...]
外层 = L1 章节,内层 = 该章节下所有段落(扁平)
注: build() 负责按 max_paragraphs_per_l2 等长分块为 L2 组"""
def _detect_toc(self, text: str) -> bool:
"""检测文本是否含 # 或 ## 开头的 Markdown 标题行(正则: ^#{1,2}\s+\S"""
def _segment_with_regex(self, text: str) -> List[List[str]]:
"""正则解析 #/## 标题边界:
# → L1 切换(flush 当前 section
## → 段落分隔(flush 当前段落,标题文本作为一段)
空行 → 段落分隔
### 及以下 → 视为普通段落内容"""
def _segment_with_llm(self, text: str) -> List[List[str]]:
"""LLM 单次调用语义分段,返回只有一个外层元素的 list(整篇视为单 L1)
Prompt: '将以下文本分成若干语义段落...只返回 JSON 数组...'
解析: json.loads(),失败时通过 ensure() 抛 ValueError
支持 LLM 返回值被 markdown 代码块包裹(正则提取)"""
def _collect_paragraphs(self, text: str) -> List[str]:
"""保底策略: 按双换行符切分段落(_segment_with_regex 无结果时兜底)"""
def _build_l2(self, paragraphs: List[str], l2_id: str) -> L2Node:
"""段落组 → L2Node(不含 children,由 build() 填充)
LLM prompt: _L2_PROMPT.format(text="\n\n".join(paragraphs))
注: 实际由 build() 统一调 batch_chat() 批量处理,此方法仅供单独调用"""
def _build_l3_from_paragraphs(
self, paragraphs: List[str], l1_i: int, l2_j: int
) -> List[L3Node]:
"""段落列表批量嵌入 → L3Node 列表(不调用 LLM
description = raw_content = 原始段落文本
embed.embed(paragraphs) 一次调用获取全部向量 [N, D]
节点 ID: f"l1_{l1_i}_l2_{l2_j}_l3_{k}" """
def _build_l1(self, l2_children: List[L2Node], l1_id: str) -> L1Node:
"""聚合所有 L2 描述 → L1Node(含 children
LLM prompt: _L1_PROMPT.format(l2_descriptions="1. ...\n2. ...")
节点 ID: f"l1_{l1_i}" """
```
#### 关键实现决策
| 决策 | 说明 |
|------|------|
| **批量 LLM** | `build()` 收集所有 L2 段落组后调用 `llm.batch_chat()` 一次并发生成所有 L2 摘要,避免串行延迟 |
| **L2 等长分块** | 当段落数超过 `max_paragraphs_per_l2` 时,`_chunk(lst, size)` 等长切块(固定步长无重叠),同一 `#` 章节下可产生多个 L2 |
| **L3 无 LLM** | L3 直接复用原始段落文本(`description == raw_content`),`embed.embed()` 批量调用 |
| **节点 ID** | `l1_{i}` / `l1_{i}_l2_{j}` / `l1_{i}_l2_{j}_l3_{k}`,全局唯一 |
| **Prompt 常量** | `_L2_PROMPT`, `_L1_PROMPT`, `_SEG_PROMPT` 定义在模块顶层 |
#### Prompt 设计
```python
_L2_PROMPT = "用1-2句话描述以下段落的核心内容,与同级小节形成区分:\n\n{text}"
_L1_PROMPT = "用2-3句话总结以下小节的核心内容:\n\n{l2_descriptions}"
_SEG_PROMPT = "将以下文本分成若干语义段落,每段为完整语义单元。\n只返回 JSON 数组,格式: [\"段落1\", ...],不要其他内容。\n文本:\n\n{text}"
```
**依赖**: `tree_index`, `embeddings`, `llm_client`, `utils.logger_system`
---
### 1.5 video_tree_builder.py — 视频树构建
**文件**: `video_tree_trm/video_tree_builder.py`
**职责**: 长视频 → TreeIndex,实现 L2 轴心构建策略 + VLM 帧描述。
**状态**: ✅ 已实现
> **注意**: 构造器不接受 `EmbeddingModel`(延迟 embedding 设计)。所有节点 `embedding=None`,由 `Pipeline.embed_all()` 在检索前统一填充。支持本地文件路径和 YouTube URL 两种输入。
```python
class VideoTreeBuilder:
"""视频模态树构建器"""
def __init__(self, vlm: LLMClient, config: TreeConfig):
self.vlm = vlm
self.config = config
# ⚠ 无 embed_modelembedding 延迟到 Pipeline.embed_all()
def build(self, video_path: str) -> TreeIndex:
"""
支持本地文件路径或 YouTube URL。
URL 模式: _resolve_stream() 获取 CDN 直链,_get_video_duration() 获取时长
本地模式: OpenCV 直接读取
完整构建流程(ThreadPoolExecutor 异步事件循环):
Step 0: URL 处理(若 video_path 为 URL
Step 1: _segment_video → L1 时间区间列表
Step 2: 收集全局 L2 任务列表,预计算每个 L1 的 L2 数量
Step 3: ThreadPoolExecutor(max_workers=concurrency) 一次性提交所有 L2 任务(非阻塞)
Step 4: 事件循环(cfwait FIRST_COMPLETED):
L2 完成 → 立即提交 L3 任务(_build_l3_task
L3 完成 → 检查 L1 就绪 → 立即提交 L1 任务
L1 完成 → 收集结果
Step 5: 有序重建 l1_nodes,组装 TreeIndex(全部 embedding=None
主线程单线程操作 l1_l2_buckets,无竞争,无需 Lock。
"""
# ── URL 流式辅助方法(静态方法)──
@staticmethod
def _is_url(path_or_url: str) -> bool:
"""判断输入是否为 http/https URL"""
@staticmethod
def _source_stem(video_path: str) -> str:
"""提取短标识符用于帧缓存目录:
YouTube URL → 视频 IDv= 参数);本地文件 → stem(限 64 字符)"""
@staticmethod
def _resolve_stream(url: str) -> str:
"""yt-dlp -g 获取 YouTube CDN 直链(不下载,仅元数据)"""
@staticmethod
def _get_video_duration(url: str) -> float:
"""yt-dlp --dump-json 获取视频时长(cv2 在 HTTP 流上 CAP_PROP_FRAME_COUNT 不可靠)"""
# ── 内部方法 ──
def _segment_video(
self, video_path: str, duration_hint: Optional[float] = None
) -> List[Tuple[float, float]]:
"""固定步长切分 L1 区间。
本地文件: cv2 读取总时长;HTTP 流: 使用 duration_hint"""
def _get_l2_clips(self, l1_range: Tuple[float, float]) -> List[Tuple[float, float]]:
"""将 L1 区间按 l2_clip_duration 等分为 L2 clips"""
def _extract_frames(
self, video_path: str, time_range: Tuple[float, float], fps: float,
source_id: Optional[str] = None
) -> List[Tuple[str, float]]:
"""L3 专用:按 fps 密集提取帧到 {cache_dir}/frames/{source_id}/
已存在的帧文件自动跳过(缓存复用)"""
def _build_l2_video(
self, video_path: str, clip_range: Tuple[float, float], l2_id: str,
source_id: Optional[str] = None
) -> L2Node:
"""稀疏均匀 seek l2_representative_frames 帧 → VLM 描述(1-2句)
embedding=None"""
def _build_l3_video(
self, frames: List[Tuple[str, float]], l2_description: str, l1_i: int, l2_j: int
) -> List[L3Node]:
"""注入 L2 上下文的 VLM 批量帧描述
- 主路径: 一次 VLM 调用,要求返回 JSON 数组
- 降级路径: JSON 解析失败时逐帧调用
所有节点 embedding=None"""
def _build_l3_task(
self,
video_path: str,
l2_node: L2Node,
clip_range: Tuple[float, float],
source_id: str,
l1_i: int,
l2_j: int,
) -> L2Node:
"""L3 线程任务单元:提取帧 + _build_l3_video,返回已填充 children 的 L2Node。
由事件循环在 L2 完成后自动提交(非阻塞),线程安全(内部独立持有 VideoCapture)。"""
def _call_vlm_batch(self, prompt, frame_paths, n, l1_i, l2_j) -> List[str]:
"""批量 VLM 调用 + JSON 解析失败时降级逐帧"""
def _parse_json_descriptions(self, raw: str, expected_n: int) -> Optional[List[str]]:
"""从 VLM 输出解析 JSON 数组,长度不匹配返回 None"""
def _build_l1_video(
self, l2_children: List[L2Node], l1_id: str, l1_range: Tuple[float, float]
) -> L1Node:
"""拼接 L2 描述 → vlm.chat()(纯文本)生成 2-3 句摘要;embedding=None"""
```
**关键配置参数**`config.tree`:
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `l1_segment_duration` | 600.0s | L1 切分步长 |
| `l2_clip_duration` | 60.0s | L2 clip 时长 |
| `l3_fps` | 1.0 | L3 帧提取速率(帧/秒) |
| `l2_representative_frames` | 10 | L2 稀疏代表帧数 |
| `cache_dir` | `cache/trees` | 帧图像持久化目录 |
| `concurrency` | 16 | 视频内 L2/L3 任务并发数(ThreadPoolExecutor max_workers |
**依赖**: tree_index, embeddings, llm_client, opencv-python(帧提取)
---
### 1.6 recursive_retriever.py — TRM 递归检索器
**文件**: `video_tree_trm/recursive_retriever.py`
**职责**: 核心可训练模型。Cross-Attention 节点选择 + MLP 推理 + ACT halt。
**状态**: ✅ 已实现 | 测试: `tests/unit/test_recursive_retriever.py`17 个用例全部通过)
#### 1.6.1 CrossAttentionSelector
```python
class CrossAttentionSelector(nn.Module):
"""跨层节点选择器(共享,用于 L1/L2/L3 三个阶段)"""
def __init__(self, embed_dim: int, num_heads: int):
self.W_q = Linear(embed_dim, embed_dim)
self.W_k = Linear(embed_dim, embed_dim)
self.W_v = Linear(embed_dim, embed_dim)
self.W_o = Linear(embed_dim, embed_dim)
self.num_heads = num_heads
self.head_dim = embed_dim // num_heads
self.scale = self.head_dim ** -0.5
def forward(
self, state: Tensor, candidates: Tensor
) -> Tuple[Tensor, Tensor, Tensor]:
"""
Args:
state: [B, D] — 当前 q+z 融合状态
candidates: [B, N, D] — 该层候选节点嵌入
Returns:
selected_info: [B, D] — attention 加权节点信息(可微)
attn_weights: [B, N] — 归一化注意力权重(用于 nav loss)
selected_idx: [B] — argmax 节点索引(用于路径记录)
"""
B, N, D = candidates.shape
Q = self.W_q(state).unsqueeze(1) # [B, 1, D]
K = self.W_k(candidates) # [B, N, D]
V = self.W_v(candidates) # [B, N, D]
# reshape → multi-head
Q = Q.view(B, 1, self.num_heads, self.head_dim).transpose(1, 2) # [B, H, 1, d]
K = K.view(B, N, self.num_heads, self.head_dim).transpose(1, 2) # [B, H, N, d]
V = V.view(B, N, self.num_heads, self.head_dim).transpose(1, 2) # [B, H, N, d]
# scaled dot-product attention
attn_out = F.scaled_dot_product_attention(Q, K, V) # [B, H, 1, d]
attn_out = attn_out.transpose(1, 2).reshape(B, 1, D)
selected_info = self.W_o(attn_out).squeeze(1) # [B, D]
# 注意力权重(对 head 维度平均,用于 loss 和可解释性)
raw_scores = (Q @ K.transpose(-2, -1)) * self.scale # [B, H, 1, N]
attn_weights = raw_scores.mean(dim=1).squeeze(1).softmax(dim=-1) # [B, N]
selected_idx = attn_weights.argmax(dim=-1) # [B]
return selected_info, attn_weights, selected_idx
```
#### 1.6.2 ReasoningModuleL-level
```python
class ReasoningBlock(nn.Module):
"""单层 MLP 推理块"""
def __init__(self, dim: int, expansion: float):
self.norm = RMSNorm(dim)
self.ffn = SwiGLU(dim, int(dim * expansion))
def forward(self, x: Tensor) -> Tensor:
return self.norm(x + self.ffn(x)) # [B, D] → [B, D]
class ReasoningModule(nn.Module):
"""L-level 推理模块(多层 MLP,共享权重跨层级)"""
def __init__(self, dim: int, L_layers: int, expansion: float):
self.blocks = ModuleList([ReasoningBlock(dim, expansion) for _ in range(L_layers)])
def forward(self, z: Tensor, injection: Tensor) -> Tensor:
"""
Args:
z: [B, D] — 当前潜在状态
injection: [B, D] — 注入信息 (selected_info + q)
Returns:
z_new: [B, D]
"""
h = z + injection
for block in self.blocks:
h = block(h)
return h
```
#### 1.6.3 RecursiveRetriever
```python
class RecursiveRetriever(nn.Module):
"""TRM 递归检索器主模型"""
def __init__(self, config: RetrieverConfig):
self.selector = CrossAttentionSelector(config.embed_dim, config.num_heads)
self.L_level = ReasoningModule(config.embed_dim, config.L_layers, config.ffn_expansion)
self.q_head = Linear(config.embed_dim, 1) # ACT halt head
self.L_cycles = config.L_cycles
self.max_rounds = config.max_rounds
# q_head 初始化为倾向"继续"bias = -5 → sigmoid ≈ 0
with torch.no_grad():
self.q_head.bias.fill_(-5.0)
def forward(
self, q: Tensor, tree: TreeIndex, return_internals: bool = False
) -> Dict[str, Any]:
"""
训练/推理统一入口。
Args:
q: [B, D] — 查询嵌入(来自冻结 text_embed
tree: TreeIndex — 预构建树索引
return_internals: 是否返回中间状态(用于 loss 计算)
Returns:
{
"paths": List[Tuple[int, int, int]],
"num_rounds": int,
"z_final": Tensor [B, D],
# return_internals=True 时额外返回:
"attn_weights_per_step": List[Tensor], # 每步 [B, N]
"halt_logits": List[Tensor], # 每轮 [B, 1]
}
"""
z = q.clone() # [B, D]
paths = []
attn_weights_all = []
halt_logits_all = []
for round_idx in range(self.max_rounds):
path, z, step_attns = self._traverse_one_path(q, z, tree)
paths.append(path)
attn_weights_all.extend(step_attns)
halt_logit = self.q_head(z) # [B, 1]
halt_logits_all.append(halt_logit)
if not self.training and halt_logit.item() > 0 and round_idx > 0:
break
result = {
"paths": paths,
"num_rounds": len(paths),
"z_final": z,
}
if return_internals:
result["attn_weights_per_step"] = attn_weights_all
result["halt_logits"] = halt_logits_all
return result
def _traverse_one_path(
self, q: Tensor, z: Tensor, tree: TreeIndex
) -> Tuple[Tuple[int, int, int], Tensor, List[Tensor]]:
"""单次 L1 → L2 → L3 遍历"""
step_attns = []
# Phase 1: L1
M_L1 = torch.tensor(tree.l1_embeddings(), device=q.device) # [N1, D]
k1, z, attn_w = self._select_and_reason(q, z, M_L1.unsqueeze(0))
step_attns.append(attn_w)
# Phase 2: L2 (k1 的子节点)
M_L2 = torch.tensor(tree.l2_embeddings_of(k1), device=q.device) # [N2, D]
k2, z, attn_w = self._select_and_reason(q, z, M_L2.unsqueeze(0))
step_attns.append(attn_w)
# Phase 3: L3 (k2 的子节点)
M_L3 = torch.tensor(tree.l3_embeddings_of(k1, k2), device=q.device) # [N3, D]
k3, z, attn_w = self._select_and_reason(q, z, M_L3.unsqueeze(0))
step_attns.append(attn_w)
return (k1, k2, k3), z, step_attns
def _select_and_reason(
self, q: Tensor, z: Tensor, M: Tensor
) -> Tuple[int, Tensor, Tensor]:
"""
单层: Cross-Attention 选择 + L_cycles 内循环推理
Args:
q: [B, D], z: [B, D], M: [B, N, D]
Returns:
k_star: int, z_new: [B, D], attn_weights: [B, N]
"""
state = q + z
selected_info, attn_weights, selected_idx = self.selector(state, M)
z = z + selected_info
for _ in range(self.L_cycles):
z = self.L_level(z, selected_info + q)
return selected_idx.item(), z, attn_weights
```
**训练 vs 推理行为差异**:
| 行为 | 训练 | 推理 |
|------|------|------|
| 多轮循环 | 固定跑 max_rounds 轮 | halt_logit > 0 提前停止 |
| 梯度 | 全部可微 | no_grad |
| 返回值 | 含 attn_weights + halt_logits | 仅 paths + z_final |
**依赖**: torch, tree_index
---
### 1.7 losses.py — 损失函数
**文件**: `video_tree_trm/losses.py`
**职责**: 导航损失(cross-entropy+ ACT halt 损失(Q-learning)。
**状态**: ✅ 已实现 | 测试: `tests/unit/test_losses.py`13 个用例全部通过)
```python
class NavigationLoss(nn.Module):
"""导航监督损失:推动 attn_weights 指向正确节点"""
def forward(
self, attn_weights_list: List[Tensor], gt_path: Tuple[int, int, int]
) -> Tensor:
"""
Args:
attn_weights_list: [attn_l1, attn_l2, attn_l3],每个 [B, N]
gt_path: (gt_l1_idx, gt_l2_idx, gt_l3_idx)
Returns:
loss: scalar
"""
loss = 0
for attn_w, gt_idx in zip(attn_weights_list, gt_path):
target = torch.tensor([gt_idx], device=attn_w.device)
log_probs = attn_w.log() # [B, N]
loss += F.nll_loss(log_probs, target) # cross-entropy
return loss / 3 # 三层平均
class ACTLoss(nn.Module):
"""ACT halt Q-learning 损失"""
def __init__(self, lambda_step: float = 0.1, gamma: float = 0.9):
self.lambda_step = lambda_step
self.gamma = gamma
def forward(
self,
halt_logits: List[Tensor], # 每轮 [B, 1]
answer_qualities: List[float], # 每轮累积的答案质量 (0~1)
) -> Tensor:
"""
Q-learning target:
若在第 t 轮停止 → Q_halt = quality_t
若继续 → Q_continue = γ * max(Q_{t+1}) - λ
"""
loss = 0
n = len(halt_logits)
for t in range(n):
halt_q = answer_qualities[t]
if t < n - 1:
continue_q = self.gamma * answer_qualities[t + 1] - self.lambda_step
else:
continue_q = halt_q - self.lambda_step # 最后一轮,继续无意义
# 目标: halt_logit > 0 当 halt_q > continue_q
target = 1.0 if halt_q >= continue_q else 0.0
pred = torch.sigmoid(halt_logits[t])
loss += F.binary_cross_entropy(pred, torch.tensor([[target]], device=pred.device))
return loss / n
```
**依赖**: torch
---
### 1.8 answer_generator.py — 答案生成
**文件**: `video_tree_trm/answer_generator.py`
**职责**: 根据检索结果组装 context,调用 LLM/VLM 生成最终答案。
**状态**: ✅ 已实现 | 测试: `tests/unit/test_answer_generator.py`10 个用例全部通过)
```python
@dataclass
class RetrievalResult:
"""检索器输出的结构化结果"""
query: str
paths: List[Tuple[int, int, int]]
num_rounds: int
class AnswerGenerator:
def __init__(self, llm: LLMClient, vlm: LLMClient):
self.llm = llm
self.vlm = vlm
def generate(self, query: str, result: RetrievalResult, tree: TreeIndex) -> str:
"""
根据模态分发:
文本 → LLM(query, raw_text_chunks)
视频 → VLM(query, frame_images + captions)
"""
nodes = [tree.get_node(*path) for path in result.paths]
if tree.metadata.modality == "text":
context = "\n---\n".join(n.raw_content for n in nodes if n.raw_content)
return self.llm.chat(
f"根据以下上下文回答问题。\n\n上下文:\n{context}\n\n问题: {query}"
)
else:
frames = [n.frame_path for n in nodes if n.frame_path]
captions = [n.description for n in nodes]
caption_text = "\n".join(f"- {c}" for c in captions)
return self.vlm.chat_with_images(
f"根据以下关键帧回答问题。\n帧描述:\n{caption_text}\n\n问题: {query}",
images=frames,
)
```
**依赖**: tree_index, llm_client
---
### 1.9 pipeline.py — 端到端管线
**文件**: `video_tree_trm/pipeline.py`
**职责**: 串联 预处理 → 检索 → 生成 的完整推理流程。
**状态**: ✅ 已实现 | 测试: `tests/unit/test_pipeline.py`10 个用例全部通过)
```python
class Pipeline:
"""端到端推理管线"""
def __init__(self, config: Config):
self.embed_model = EmbeddingModel(config.embed)
self.llm = LLMClient(config.llm)
self.vlm = LLMClient(config.vlm)
self.retriever = RecursiveRetriever(config.retriever)
# 可选加载检查点(checkpoint=null 时跳过)
if config.retriever.checkpoint:
state_dict = torch.load(config.retriever.checkpoint, map_location="cpu")
self.retriever.load_state_dict(state_dict)
self.retriever.eval()
self.generator = AnswerGenerator(self.llm, self.vlm)
def build_index(self, source_path: str, modality: str) -> TreeIndex:
"""构建并缓存 TreeIndexJSON 格式,无 embedding)。
缓存路径: {cache_dir}/{stem}_{modality}.json
- 缓存命中: 直接 load_json 返回(embedding=None
- 缓存未命中: 调用 Builder 生成文字描述,save_json 持久化
⚠ 返回的 TreeIndex embedding 全为 None
query() 时会自动调用 _embed_tree() 填充。
"""
if modality == "text":
builder = TextTreeBuilder(self.llm, self.config.tree)
with open(source_path, encoding="utf-8") as f:
tree = builder.build(f.read(), source_path)
else:
builder = VideoTreeBuilder(self.vlm, self.config.tree)
tree = builder.build(source_path)
tree.save_json(cache_path) # JSON 持久化,无 embedding
return tree
def _embed_tree(self, tree: TreeIndex, cache_path: Optional[str] = None) -> None:
"""对树所有节点执行 embedding(内存中),可选回写缓存。
L3 按 L2 分组批量处理,L1/L2 各单独处理。"""
tree.embed_all(
embed_fn=self.embed_model.embed,
model_name=self.config.embed.model_name,
embed_dim=self.embed_model.dim,
)
if cache_path is not None:
tree.save_json(cache_path) # 回写(含 embedding 的 JSON,实际不存储向量)
def query(self, question: str, tree: TreeIndex) -> str:
"""问答: question → answer。
若 tree.is_embedded 为 FalseJSON 加载后),先触发 _embed_tree()。
"""
# Phase 0: 按需触发 embed_allJSON 缓存加载后 embedding=None
if not tree.is_embedded:
self._embed_tree(tree, cache_path=None)
# Phase 1: 嵌入查询
q = self.embed_model.embed_tensor(question) # [1, D]
# Phase 2: 递归检索
with torch.no_grad():
result = self.retriever(q, tree)
# Phase 3: 生成答案
return self.generator.generate(question, result["paths"], tree)
```
**与原设计的关键差异**:
| 项目 | 原设计 | 实际实现 |
|------|--------|---------|
| Builder 构造器 | `TextTreeBuilder(embed_model, llm, config)` | `TextTreeBuilder(llm, config)` |
| 缓存格式 | pickle(含 embedding | JSON(无 embedding),首次 query 时内存 embed |
| `build_index` 返回 | 含 embedding 的 TreeIndex | `embedding=None` 的 TreeIndex |
| `query` 额外逻辑 | 直接检索 | 先检查 `is_embedded`,按需调用 `_embed_tree()` |
| 新增方法 | — | `_embed_tree()` |
**依赖**: 所有其他模块
---
### 1.10 config.py — 配置管理
**文件**: `video_tree_trm/config.py`
**职责**: 所有超参数的 dataclass 类型定义(无默认值)+ 多源加载。
#### 设计原则
- **Dataclass 无默认值**: 纯类型定义 + 结构化访问,YAML 必须写全,漏写即报错。
- **三层优先级**: `CLI args > .env > YAML`,高优先级覆盖低优先级。
- **统一归口**: 无论来源,最终构造唯一 `Config` dataclass 对象,代码只与 dataclass 交互。
- **敏感信息隔离**: `api_key` 等敏感字段只写在 `.env` 中,不进 YAML 和代码。
#### 加载流程
```
Step 1: 读取 YAML → base dict(全量非敏感配置)
Step 2: 读取 .env → 覆盖 dict 中对应字段(api_key 等敏感信息)
Step 3: 解析 CLI args → 最终覆盖 dict 中对应字段
Step 4: dict → Config dataclass(校验完整性,缺字段直接报错)
```
#### Dataclass 定义
```python
@dataclass
class TreeConfig:
# 文本模式
max_paragraphs_per_l2: int # 每个 L2 节点包含的最大段落数
# 视频模式
l1_segment_duration: float # L1 段时长(秒)
l2_clip_duration: float # L2 clip 时长(秒)
l3_fps: float # L3 帧提取频率
l2_representative_frames: int # L2 VLM 描述用的代表帧数
# 通用
cache_dir: str # TreeIndex 缓存目录
@dataclass
class EmbedConfig:
model_name: str # 嵌入模型名称
embed_dim: int # 嵌入维度 D
device: str # "cuda" | "cpu"
@dataclass
class LLMConfig:
backend: str # "qwen" | "openai" | "ollama"
api_key: str # 从 .env 加载,不写入 YAML
model: str # 模型名称
api_url: str # API 端点 URL
max_tokens: int # 最大生成 token 数
temperature: float # 采样温度
@dataclass
class VLMConfig:
backend: str # "qwen" | "openai" | "ollama"
api_key: str # 从 .env 加载,不写入 YAML
model: str # 模型名称
api_url: str # API 端点 URL
max_tokens: int # 最大生成 token 数
temperature: float # 采样温度
@dataclass
class RetrieverConfig:
embed_dim: int # 嵌入维度(须与 EmbedConfig.embed_dim 一致)
num_heads: int # Cross-Attention 头数
L_layers: int # ReasoningModule 层数
L_cycles: int # 每级推理迭代次数
max_rounds: int # ACT 最大遍历轮次
ffn_expansion: float # SwiGLU 扩展比
checkpoint: Optional[str] # 训练好的模型权重路径(推理时必填)
@dataclass
class TrainConfig:
lr: float # 学习率
weight_decay: float # 权重衰减
batch_size: int # 批大小
max_epochs_phase1: int # Phase 1 导航训练轮数
max_epochs_phase2: int # Phase 2 ACT 训练轮数
nav_loss_weight: float # 导航损失权重
act_loss_weight: float # ACT 损失权重
act_lambda_step: float # ACT 步数惩罚系数
act_gamma: float # ACT 折扣因子
eval_interval: int # 每 N epoch 评估一次
save_dir: str # 模型权重保存目录
dataset: str # "longbench" | "narrativeqa" | "videomme"
dataset_path: str # 数据集路径
@dataclass
class Config:
tree: TreeConfig
embed: EmbedConfig
llm: LLMConfig
vlm: VLMConfig
retriever: RetrieverConfig
train: TrainConfig
@classmethod
def load(cls, yaml_path: str, cli_args: Optional[dict] = None) -> "Config":
"""
三层合并加载:
1. 读取 YAML → base dict
2. 读取 .env → 覆盖 api_key 等敏感字段
3. cli_args → 最终覆盖
4. dict → Config(缺字段报 TypeError
"""
...
```
#### 文件分工
| 文件 | 内容 | 提交到 Git |
|------|------|-----------|
| `config/default.yaml` | 全量非敏感配置(必须写全所有字段) | 是 |
| `.env` | 敏感信息(api_key 等) | 否 |
| `.env.example` | `.env` 模板(值留空) | 是 |
#### YAML 示例 (`config/default.yaml`)
```yaml
tree:
max_paragraphs_per_l2: 5
l1_segment_duration: 600.0
l2_clip_duration: 20.0
l3_fps: 1.0
l2_representative_frames: 3
cache_dir: "cache/trees"
embed:
model_name: "BAAI/bge-base-zh-v1.5"
embed_dim: 768
device: "cuda"
llm:
backend: "qwen"
model: "qwen-plus"
api_url: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"
max_tokens: 256
temperature: 0.1
# api_key: 从 .env 加载,此处不写
vlm:
backend: "qwen"
model: "qwen-vl-plus"
api_url: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"
max_tokens: 256
temperature: 0.1
# api_key: 从 .env 加载,此处不写
retriever:
embed_dim: 768
num_heads: 4
L_layers: 2
L_cycles: 4
max_rounds: 5
ffn_expansion: 2.0
checkpoint: null
train:
lr: 1.0e-4
weight_decay: 1.0e-5
batch_size: 1
max_epochs_phase1: 30
max_epochs_phase2: 20
nav_loss_weight: 1.0
act_loss_weight: 0.1
act_lambda_step: 0.1
act_gamma: 0.9
eval_interval: 5
save_dir: "checkpoints"
dataset: "longbench"
dataset_path: "data/longbench"
```
#### .env 示例
```bash
# .env — 敏感信息,不提交到 Git
LLM_API_KEY=sk-xxx
VLM_API_KEY=sk-xxx
```
**依赖**: dataclasses, yaml, python-dotenv
---
## 2. 训练管线
**文件**: `train.py`(项目根目录)
**状态**: ✅ 已实现 | 测试: `tests/unit/test_train.py`13 个用例全部通过)
### 2.1 数据准备
```python
def prepare_training_data(config: Config) -> List[Dict]:
"""
离线预处理:
1. 加载 QA 数据集(LongBench / NarrativeQA
2. 为每个文档构建 TreeIndex(缓存到 cache_dir
3. 推导每个 QA 对的 ground truth 路径
Returns:
[{"query": str, "tree": TreeIndex, "gt_path": (l1, l2, l3), "answer": str}, ...]
"""
```
### 2.2 Ground Truth 路径推导
```python
def find_gt_path_text(tree: TreeIndex, answer: str) -> Optional[Tuple[int, int, int]]:
"""
文本模式: 找到与答案文本重叠度最高的 L3 节点
评分: F1(L3.raw_content, answer) — token 级别
返回: (l1_idx, l2_idx, l3_idx) 或 None
"""
best_score, best_path = 0, None
for i, l1 in enumerate(tree.roots):
for j, l2 in enumerate(l1.children):
for k, l3 in enumerate(l2.children):
score = token_f1(l3.raw_content, answer)
if score > best_score:
best_score = score
best_path = (i, j, k)
return best_path
def find_gt_path_video(tree: TreeIndex, timestamp: float) -> Optional[Tuple[int, int, int]]:
"""
视频模式: 找到最接近目标时间戳的 L3 帧
"""
for i, l1 in enumerate(tree.roots):
if l1.time_range[0] <= timestamp <= l1.time_range[1]:
for j, l2 in enumerate(l1.children):
if l2.time_range[0] <= timestamp <= l2.time_range[1]:
k = min(range(len(l2.children)),
key=lambda k: abs(l2.children[k].timestamp - timestamp))
return (i, j, k)
return None
```
### 2.3 两阶段训练策略
```
Phase 1: 导航训练(单轮, max_rounds=1
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
目标: 训练 Selector + L_level 正确导航到目标节点
损失: NavigationLoss (cross-entropy on attn_weights)
可训练: CrossAttentionSelector, ReasoningModule
冻结: text_embed, q_head, TreeIndex embeddings
Phase 2: ACT 训练(多轮, max_rounds=5
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
目标: 训练 q_head 判断何时停止检索
损失: NavigationLoss + λ * ACTLoss
可训练: 全部(Selector + L_level + q_head
冻结: text_embed, TreeIndex embeddings
ACT reward: answer_quality (F1/EM) - λ_step * rounds
```
### 2.4 训练循环伪代码
```python
def train(config: Config):
# ── 初始化 ──
embed_model = EmbeddingModel(config.embed.model_name, config.embed.device)
retriever = RecursiveRetriever(config.retriever).to(config.embed.device)
nav_loss_fn = NavigationLoss()
act_loss_fn = ACTLoss(config.train.act_lambda_step, config.train.act_gamma)
dataset = prepare_training_data(config)
optimizer = AdamW(retriever.parameters(), lr=config.train.lr)
# ── Phase 1: 导航训练 ──
retriever.max_rounds = 1
for epoch in range(config.train.max_epochs_phase1):
for sample in dataset:
q = embed_model.embed_tensor(sample["query"]).to(device) # [1, D]
tree = sample["tree"]
gt_path = sample["gt_path"]
result = retriever(q, tree, return_internals=True)
# result["attn_weights_per_step"] = [attn_l1, attn_l2, attn_l3]
loss = nav_loss_fn(result["attn_weights_per_step"][:3], gt_path)
optimizer.zero_grad()
loss.backward()
optimizer.step()
# ── Phase 2: ACT 训练 ──
retriever.max_rounds = config.retriever.max_rounds
llm = LLMClient(config.llm.backend, config.llm.api_key, config.llm.model)
generator = AnswerGenerator(llm, None)
for epoch in range(config.train.max_epochs_phase2):
for sample in dataset:
q = embed_model.embed_tensor(sample["query"]).to(device)
result = retriever(q, sample["tree"], return_internals=True)
# 每轮计算答案质量
qualities = []
for round_idx in range(result["num_rounds"]):
paths_so_far = result["paths"][:round_idx + 1]
nodes = [sample["tree"].get_node(*p) for p in paths_so_far]
context = "\n".join(n.raw_content for n in nodes if n.raw_content)
answer = llm.chat(f"上下文: {context}\n问题: {sample['query']}")
quality = token_f1(answer, sample["answer"])
qualities.append(quality)
# 导航 loss(仅第一轮)
loss_nav = nav_loss_fn(result["attn_weights_per_step"][:3], sample["gt_path"])
# ACT loss
loss_act = act_loss_fn(result["halt_logits"], qualities)
# 总损失
loss = loss_nav + config.train.act_loss_weight * loss_act
optimizer.zero_grad()
loss.backward()
optimizer.step()
```
---
## 3. 实验计划
### 3.1 数据集
| 数据集 | 模态 | 样本量 | 任务类型 | 优先级 |
|--------|------|--------|----------|--------|
| LongBench | 文本 | ~5K | 长文本 QA | P0 (首发) |
| NarrativeQA | 文本 | ~30K | 叙事理解 QA | P1 |
| VideoMME | 视频 | ~2K | 视频 QA (多选) | P2 |
### 3.2 评估指标
| 指标 | 适用 | 计算方式 |
|------|------|----------|
| EM (Exact Match) | 文本 QA | 标准化后精确匹配 |
| F1 | 文本 QA | token 级 precision/recall |
| Accuracy | 视频 QA | 选项匹配正确率 |
| Avg Rounds | 全部 | 平均检索轮次(衡量效率) |
| Nav Accuracy | 全部 | 第一轮 L1/L2/L3 各层命中率 |
### 3.3 Baselines
| 方法 | 描述 |
|------|------|
| BM25 + LLM | 传统稀疏检索 baseline |
| Dense Retrieval + LLM | BGE 向量检索 + rerank |
| PageIndex (原论文) | 无 TRM 的树状导航 (cosine routing, 无推理模块) |
| Tree-TRM (原论文) | 原始 tree_trm.py 实现 |
### 3.4 消融实验
| 实验 | 变量 | 目的 |
|------|------|------|
| A1 | Cross-Attention vs Cosine 路由 | 验证 CA 选择器的增益 |
| A2 | L_cycles = {1, 2, 4, 8} | 推理深度对准确率的影响 |
| A3 | L_layers = {1, 2, 4} | 推理模块复杂度 |
| A4 | max_rounds = {1, 3, 5} | 多轮检索的边际收益 |
| A5 | 有/无 ACT halt | ACT 机制对效率的贡献 |
| A6 | num_heads = {1, 4, 8} | 注意力头数的影响 |
---
## 4. 文件结构与依赖
### 4.1 目录树
```
Video-Tree-TRM/
├── video_tree_trm/ # 主包
│ ├── __init__.py
│ ├── config.py # §1.10 配置管理
│ ├── tree_index.py # §1.1 统一数据结构
│ ├── embeddings.py # §1.2 嵌入服务
│ ├── llm_client.py # §1.3 LLM/VLM 客户端
│ ├── text_tree_builder.py # §1.4 文本树构建
│ ├── video_tree_builder.py # §1.5 视频树构建
│ ├── recursive_retriever.py # §1.6 TRM 递归检索器
│ ├── losses.py # §1.7 损失函数
│ ├── answer_generator.py # §1.8 答案生成
│ └── pipeline.py # §1.9 端到端管线
├── utils/
│ ├── __init__.py
│ └── logger_system.py # 日志系统 (log_msg, ensure, log_exception)
├── config/
│ ├── default.yaml # 默认配置(通用)
│ └── videomme.yaml # VideoMME 实验专属配置(GPUStack Qwen3-VL
├── tests/
│ ├── conftest.py # 全局 fixturereal_config, 代理修复)
│ ├── unit/
│ │ ├── test_config.py # ✅ 已实现
│ │ ├── test_embeddings.py # ✅ 已实现
│ │ ├── test_llm_client.py # ✅ 已实现
│ │ ├── test_tree_index.py # ✅ 已实现
│ │ ├── test_text_tree_builder.py # ✅ 已实现(43 用例)
│ │ ├── test_recursive_retriever.py # ✅ 已实现(17 用例)
│ │ ├── test_losses.py # ✅ 已实现(13 用例)
│ │ ├── test_answer_generator.py # ✅ 已实现(10 用例)
│ │ ├── test_pipeline.py # ✅ 已实现(10 用例)
│ │ └── test_train.py # ✅ 已实现(13 用例)
│ ├── integration/
│ └── outputs/ # Agent 测试 MD 输出
│ └── text_tree_builder/ # ✅ build_toc_*.md
├── data/ # 数据集(不提交)
├── cache/ # TreeIndex 缓存(不提交)
├── checkpoints/ # 模型权重(不提交)
├── logs/ # 运行日志(不提交)
├── train.py # §2 训练入口
├── main.py # 推理/演示入口
├── docs/
│ ├── architecture.md # 架构设计(理念层)
│ └── TD.md # 本文档(实现层)
├── .env # API 密钥(不提交)
├── .env.example # 环境变量模板
└── requirements.txt
```
### 4.2 模块依赖关系
```
config.py ← (所有模块都依赖)
embeddings.py ← text_tree_builder.py
← video_tree_builder.py
← pipeline.py
llm_client.py ← text_tree_builder.py
← video_tree_builder.py
← answer_generator.py
← pipeline.py
tree_index.py ← text_tree_builder.py
← video_tree_builder.py
← recursive_retriever.py
← answer_generator.py
← pipeline.py
recursive_retriever.py ← pipeline.py
← train.py
losses.py ← train.py
answer_generator.py ← pipeline.py
← train.py (Phase 2, 计算 answer quality)
```
### 4.3 Python 依赖
```
# 核心
torch>=2.0
sentence-transformers>=2.2
numpy
# LLM/VLM
openai>=1.0 # 兼容 Qwen/OpenAI/Ollama 接口
python-dotenv
# 视频处理
opencv-python
# 配置
pyyaml
# 测试
pytest
pytest-cov
# 代码质量
ruff
```