GraMMA 讨论了一种需要同时查关系、看图像的问答任务。它把语言模型放进多模态知识图谱,让模型从问题中的实体出发,一步步寻找答案所需的证据。论文的主要设计可以归到三个模块:结构化子图探索、关系视觉对齐与核验,以及基于轨迹的策略改进。
阅读这篇论文时,容易把子图、四种动作、视觉打分、反思和微调记成一组并列技术。它们实际处在不同层次:动作属于探索接口,RVA 是视觉模块中的评分器,反思和微调又以不同方式改变后续行为。先把这些从属关系理清,再进入公式和源码,方法会更容易理解。
本文先用一道问题说明任务,再借论文原图建立总览,随后按三个模块展开。理解各部分后,我们将它们拼回一次完整问答,最后核对实验结论与当前实现的边界。正文中的自绘图是辅助解释,论文原图则保留作者的框架与实验表达。
✏️ 材料与范围
本文依据《GraMMA: A Graph-Conditioned Multi-Modal Agent for Fine-Grained Knowledge Reasoning》版本,以及公开仓库 GraMMA。[1][2]
1. GraMMA 要回答怎样的问题?
假设用户问:“画家 A 创作的、画面中有红色帆船的作品,收藏在哪座城市?”这里的画家、作品和地点都是虚构的,后文一直用同一个例子解释机制。
图谱能告诉我们 A 创作了作品 B、C,它们分别收藏在某个博物馆,博物馆又位于某座城市。沿任意一条收藏路径,都能得到一个城市名称。但只有画面满足“红色帆船”的作品,才能支持这道题的答案。
普通知识图谱用三元组表达事实,例如 (作品 C, 收藏于, 博物馆 E)。答案需要经过中间实体时,就形成多跳推理:“作品 C → 博物馆 E → 城市 F”包含两次关系连接。多模态知识图谱(MMKG)还关联图像等材料,GraMMA 尤其关注边及其相关视觉证据。
视觉依赖描述的是另一件事:某个条件需要图像才能判断。因此一道题可以既是多跳题,又依赖视觉;“走了几跳”和“是否需要看图”不是互斥分类。
对 Agent 而言,困难在于连续做出合适的取证决策。它需要识别起点,找到作品候选,确认视觉条件,再沿收藏和地理关系追踪城市。查得太少可能漏掉约束,查得太多又容易偏离问题。GraMMA 通过显式图状态和分层证据访问来组织这个过程,并尝试从历史轨迹中改进后续决策。
2. 方法总览:三个模块各自负责什么?
先看论文的整体框架。图的左侧完成实体识别和起点定位,中部是 Agent 与可见子图之间的交互,右上角引入视觉证据,下方则从轨迹中诊断错误、更新规则与记忆。
![原论文 Fig. 1:GraMMA 方法总览。来源为所读 GraMMA_final.pdf 第 5 页,裁取图像区域,未改动内部内容。点击图片可放大查看。[1]](/_astro/paper-fig1-overview.D-aK8l6B_ZC74Oj.webp)
这张图对应的阅读地图如下。后文三个模块的名字与这里保持一致:
| 模块 | 负责的问题 | 内部机制 | 对应论文 |
|---|---|---|---|
| 模块一:结构化子图探索 | 当前知道什么,接下来还能查哪里? | 局部观察、动作接口、探索记忆与摘要 | §3.2 |
| 模块二:关系视觉对齐与核验 | 图像怎样帮助选路,何时需要更多视觉材料? | 候选视觉分数、RVA 训练、CrossModal | §3.3 |
| 模块三:轨迹反馈与策略改进 | 这次哪里出了问题,后续行为怎样改变? | 结果评估、失败诊断、经验注入、可选离线微调 | §3.4 |
论文 §3.1 定义问题、图谱和交互历史;§3.5 讨论停止探索与答案输出控制。这两部分为方法提供输入输出和边界,不必再记成两个并列创新模块。
模块一和模块二在一道题内部交织运行。模型观察局部图、选择动作,排序和取证过程中又使用视觉信息。模块三处理的是运行后留下的轨迹,它影响后续题目的指导规则或可选的离线训练。把三个模块画成依次执行一次的流水线,会丢失这种关系。
也可以用两个时间尺度来记:内层循环收集这道题的证据,外层反馈利用已经发生的轨迹。当前模型在一道题中重新检查自己,属于内层反思;把错误建议交给下一题,属于跨题经验注入;训练新参数则是另一条离线路径。
3. 模块一:结构化子图探索
第一个模块构成 Agent 的主体。它要让模型看清当前局部状态,提供有限的行动接口,并把取得的信息留给下一次决策。下面依次看观察、动作和状态更新,这三部分属于同一个探索循环。[3]
3.1 观察:从局部图到模型输入
先回到开头的题目:要找画家 A 的一幅作品,再沿收藏关系找到城市。后台即使已经有完整图谱,模型也不会因此自动知道每个节点连着谁。调用语言模型时,程序仍要明确交给它一份可读输入;论文所说的显式子图,在当前实现里就是这样进入决策的。
SubgraphSerializer 负责把图数据转换成结构化文本。它从当前位置读取邻接记录,先整理和筛选,再将关系、方向、图片计数与视觉摘要写进消息。这里没有要求主模型直接读取图数据库,也不是先把图画成图片交给它识别。[3]
原始邻接记录主要有 target、relation、direction 和 images。每条三元组在头尾两端各建立一份记录,从头实体看是出边,从尾实体看是入边。这使搜索能沿两个方向访问实体,同时保留原始关系的语义方向。
同一目标实体可能与当前节点存在多条关系。程序先按目标合并这些边,再评分并保留 Top-K 个目标实体,默认 K 为 20。于是“模型可以看到谁”已经受到程序筛选影响,不是直接由语言模型对完整邻域作出选择。
下面是按实际字段组织的观察示意,实体、数值和标题均为教学构造,格式做了简化:
=== OBSERVATION ===
Query: Which city holds painter A's work with a red sailboat?
Current node: Painter_A (degree: 12, images: 20)
Step: 1/8 | Explored: {Painter_A}
[NEIGHBORS of Painter_A]
N1: Work_B
edges: --[created]--> (3 imgs)
total_images: 3, target_degree: 5
visual: caption: "A boat beside the shore"
N2: Work_C
edges: --[created]--> (2 imgs)
total_images: 2, target_degree: 4
visual: caption: "A red sailboat on the water"
[ACTIONS]
EXPAND(id)
FOCUS(id)
CROSS_MODAL(id)
STOP
=== END OBSERVATION ===
Current node 表示当前位置;Step 提示剩余预算;Explored 和候选的 [explored] 标记帮助识别重复访问。N1、N2 是当前轮的临时编号,程序把它们解析为实体名称。扩展到新节点后,候选编号会重建。
方向箭头表示实体怎样连接。两个计数描述材料和连接规模:度数是邻接记录数,不一定等于去重邻居数;图片数也是关联记录的计数,不能直接当作独立且已核验的证据数。visual 则提供模型能直接阅读的视觉摘要,具体来源放在模块二解释。
读这份示意时,可以跟着模型做一个判断:它看见 Work_C 的描述更接近红色帆船,因此可能优先查看 C;它又知道当前还在 Painter_A,尚未取得收藏机构和城市信息,所以不能只凭画面就回答城市。名字、箭头和描述分别提供不同信息,只有结合问题才形成行动依据。这是教学分析,不是一次已验证的模型决策。
程序筛选与模型决策因此需要分别看。图片数、度数等明确进入排序;关系方向、访问标记与视觉文本进入提示词,由模型决定怎样使用。代码可以证明字段被提供或参与了评分,却不能单独证明某次动作主要由哪个字段造成。候选没有进入这份观察,与模型看见后没有选它,是两种不同的失败位置。
3.2 行动:移动、查看详情和展开视觉材料
模型每轮输出一项动作,程序负责解析和执行。四种动作的目标如下:
| 动作 | 读取什么 | 是否改变当前位置 |
|---|---|---|
EXPAND(Ni) | 移到该邻居后,读取它的局部观察 | 是 |
FOCUS(Ni) | 该邻居的详细节点信息、全部邻接关系和更多相关材料 | 否 |
CROSS_MODAL(Ni) | 当前节点与该邻居之间的详细视觉材料 | 否 |
STOP | 不再继续取证,要求给出答案与支持证据 | 结束探索 |
在画家 A 处,FOCUS(C) 能先查看作品 C 的完整邻接关系,可能已经看到它收藏于博物馆 E;当前位置仍是 A。EXPAND(C) 则真正把中心移到 C,为后续继续访问 E 建立新一轮候选。两者都能取得信息,但对搜索位置和下一轮动作引用的影响不同。
提示词建议模型按缺少的信息选择动作,没有专门的“Focus 还是 Expand”分类器。Focus 取得的新内容主要通过上下文影响模型,程序不会据此自动重算当前候选分数。底层材料不变时,重复 Focus 也基本返回同样内容,不会自动翻出下一批细节。
候选列表中的动作单位还是邻居实体。即使 A 和 C 之间有多条边,EXPAND(N2) 也没有显式指定唯一关系。这是理解后续记忆与视觉聚合时需要保留的接口边界。
Expand 详解:从初始观察到下一轮探索
这里先把 Expand 单独展开。它牵涉三个容易混在一起的范围:后台完整图谱、当前节点的一跳观察,以及本题已经积累的探索记录。只有分清它们,才能理解模型是在现有信息上选择目标,还是程序在替它搜索路线。以下仍依据本文固定的代码版本,例子为教学构造。[3]
新问题到来时,还没有一块需要继续沿用的旧子图
完整知识图谱已在后台加载,节点、关系与关联图片都已经存在。新问题进入 GraphAgent.run() 后,系统提取问题中的实体短语,在图谱实体中匹配候选,再结合问题词与候选周围关系选择起点。此处的定位负责确定从哪里开始,不代表已经取得回答所需的整条关系链。
每道题都会初始化自己的 current_entity、已访问集合、动作记录和探索记忆。前一道题的位置与完整探索历史不会自动延续到新题;在线经验入口可以追加先前总结的建议,但那是额外提示,不是继承旧子图。
以“作品 C 收藏的博物馆位于哪座城市”为例,起点确定为 C 后,初始状态如下:
| 对象 | 第一轮决策前的内容 |
|---|---|
| 后台图谱 | 完整实体、关系与关联材料,程序可以读取 |
current_entity | 作品 C |
| 已访问集合 | 仅作品 C |
| 动作历史与路径 | 空 |
| 初始观察 | 由程序读取 C 的邻域后生成 |
第一份观察由程序直接构造,不要求模型先调用 Expand。此时“将起点加入已访问集合”只是初始化,也不意味着模型已经走完一条边。
flowchart TD
Q["新问题"] --> E["提取实体短语,并匹配图谱实体"]
G["后台完整知识图谱"] --> E
E --> S["选择起点:作品 C"]
S --> I["初始化本题位置、访问集合和历史"]
I --> O["程序构建 C 的一跳观察"]
O --> L["LLM 收到初始观察,开始选择动作"]
一跳观察的范围,是当前实体直接相连的连接
假设完整图中有这些事实:
flowchart LR
A["画家 A"] -->|创作| C["作品 C"]
C -->|收藏于| E["博物馆 E"]
C -->|题材| B["帆船"]
E -->|位于| F["城市 F"]
E -->|馆长| D["人物 D"]
当前中心为 C 时,程序读取 mmkg.adjacency[C]。普通观察的候选来源是 A、E 和帆船,以及它们与 C 之间的关系。程序不会在这一轮沿 E 再取出城市 F、人物 D 的关系,也不会自动补全所有候选邻居之间的连接。
因此,这里的局部观察接近“以一个中心为核心的星形邻域”,不是将若干候选节点组成的全部诱导子图完整展开。程序可能读取 E 的度数作为排序特征,但知道 E 有几条连接,不等于已经把这些连接展示给模型。
入边也包含在范围内。即使原事实是 A 创作 C,Agent 在 C 处仍能看见 A,并沿这条连接访问 A;观察中的方向箭头用来区分事实方向和访问方向。
合并、筛选与目标选择的分工
读取邻接记录后,程序先按目标实体归组。如果 C 与 E 同时存在“收藏于”和“展出于”两条关系,E 通常只占一个候选位置,其下保留两条边。这里合并的是同目标的连接记录,不包含 E 自己的下一层邻居,也不是把一段多跳路径融合成一个候选。
接着,程序为候选实体评分、排序并截取 Top-K,默认最多展示 20 个目标。基础评分考虑名字或关系与问题的字符串匹配、图片数、目标度数,以及可用时的问题与图像 CLIP 相似度;特定入口还会加入 RVA。评分公式已在模块二展开。这里的作用范围要先确定:它处理当前中心的一跳候选,不是对完整图谱做多跳路径召回。
模型拿到的是筛选后的观察,再结合问题、历史和摘要选择动作。程序决定本轮展示谁,模型决定要不要移动,以及移动到哪里。最高分候选不会被程序直接当作必走路线。
current_entity 是当前位置,action.target 才是这次选择
程序记录的 current_entity 决定当前观察围绕谁生成;模型输出的 action.target 表示这一次希望查看哪个实体。两者不要求相同。模型也不只参考最新邻居,它可以利用仍保留在上下文中的旧观察与摘要,重新挑选一个已经知道的目标。
正常接口是 EXPAND(Ni)。程序利用本轮 neighbor_map 将临时编号解析成实体,移动后重新建立编号。因此上一轮的 N2 与下一轮的 N2 可能指向不同实体。
当前解析器还接受直接实体名:它先查候选编号,再尝试在图谱实体中进行名称匹配。于是,当模型在历史里记得作品 B、想返回另一个分支时,可以直接输出 EXPAND(Work_B)。执行分支没有严格验证目标属于当前邻居,所以代码允许比提示词所说的邻居移动更宽松的中心切换。
这不等于程序提供了一个全局语义重检索工具。目标是模型根据当前可用上下文选出的,解析器只是把名称落实为实体;模型不能因而直接看到整个后台图谱。历史被压缩或遗漏后,也不能保证相关目标仍然可用。
| 情况 | 实际含义 |
|---|---|
EXPAND(N2) | 选择当前候选映射中的实体,属于正常邻居接口 |
EXPAND(Work_B) | 直接指定模型已知的实体名,解析器支持名称匹配 |
| 程序重新检索关键实体 | Expand 本身没有实现这一步 |
| 搜索已探索子图的全部边界 | 普通观察构造没有自动执行这种全局边界搜索 |
Expand 是动作接口,执行逻辑在主循环里
论文称其为原子动作。从 Agent 使用角度,它具有工具的作用;当前代码没有独立的 def expand(...),也没有把它注册成原生 function-calling schema。提示词要求模型输出文本,parse_action() 解析为 Action,主循环再按动作类型执行。
例如,模型输出 Action: EXPAND(N2) 后,可以得到 type="EXPAND"、target="Museum_E"、raw_arg="N2"。模型显式传入的是目标;问题、当前位置、访问集合和预算由程序持有。
下面摘录实际 Expand 分支的关键部分,省略了返回消息包装:
if action.type == "EXPAND":
prev_entity = current_entity
current_entity = action.target
explored.add(current_entity)
rels = self._get_relations_between(prev_entity, current_entity)
memory.add_expand(
step_num, prev_entity, current_entity, rels, action.raw,
)
observation = self.serializer.serialize_observation(
entity=current_entity,
query=query,
explored=explored,
step=step_num + 1,
max_steps=self.config.max_steps,
level="L2",
query_emb=query_emb,
)
neighbor_map = self.build_neighbor_map(
current_entity, query, query_emb
)
代码不在这里重新判断哪个目标最好,也不重新提取问题实体。动作目标已经选好,分支负责移动中心、记录访问,再生成新观察。如果两个实体之间有多条关系,_get_relations_between() 会取出关系名称列表,动作没有指定唯一要走的关系。对直接名称跳转,也不能假定两点间一定找到连接。
一次移动一跳,但新观察可以返回多个邻居
模型在 C 处选择 E 后,先把 current_entity 改为 E,再读取 E 的一跳邻域。这时才可能展示 E 位于 F、E 的馆长为 D,以及返回 C 的连接。一次正常 Expand 只移动到一个目标,但返回的信息可以覆盖新中心的多个候选。
sequenceDiagram
participant P as 程序与本题状态
participant G as 完整知识图谱
participant L as LLM
P->>G: 读取作品 C 的一跳邻域
G-->>P: C 收藏于 E,以及其他直接连接
P->>L: 原问题与 C 的初始观察
L-->>P: Action: EXPAND 到 E
Note over P: 当前位置改为 E,记录本次访问
P->>G: 读取 E 的一跳邻域,归组并筛选
G-->>P: E 位于 F,以及其他直接连接
P->>L: 对话历史与 E 的新观察
L-->>P: 继续取证,或者 STOP 并回答 F
第一次提取出 C,只解决了起点定位。初始一跳能取得收藏机构 E,但还不一定知道城市;扩展到 E 后,才看到地理关系。因此多跳取证由多轮决策逐步完成,不要求先召回一条完整路线。看到 E 位于 F 后,模型也可能直接回答,不必为了把 F 加入当前位置再执行一次 Expand。
本题的子图状态更新,不是改写原始图谱
执行 Expand 改变当前位置、已访问集合、动作路径和程序记忆,原始三元组不因此新增或改写。新邻域进入消息历史后,模型可用的证据增加;但程序记忆通常记录本次访问连接,并不自动把这轮展示的所有候选边都加入证据边集合。
于是要分开“后台存在”“本轮看见”“已经访问”和“被记为证据”四种状态。F 可以已经出现在 E 的观察里,却没有被访问过;E 的所有候选也未必都留下完整长期记录。历史压缩后,这些信息仍可能被缩减。
同一道题的下一轮保留当前位置和已有上下文,不会重新走一次起点提取流程。模型可以沿当前邻居继续,也可以参考上下文换一个已知目标;程序按最终目标生成新的局部观察。更准确的整体理解是:局部观察随当前位置切换,探索历史随动作积累,下一步目标则由模型在当前可用信息上重新决定。
Focus 详解:保留当前位置,展开目标详情
理解了 Expand 后,再看 Focus,二者的重叠就比较容易定位了。它们都会读取目标实体周围的信息,区别在于读取粒度,以及是否切换当前位置和下一轮候选。先把概念固定下来:模型根据问题和已有探索上下文选择动作;动作本身执行取数和状态更新,不在内部再进行一轮独立的推理决策。[3]
Expand 的执行也不只是移动。它会改变 current_entity、记录访问,再为新中心构造筛选后的观察。Focus 则读取目标的详细邻接信息,加入对话,但保留原来的中心和候选映射。因此,查看邻域并不是 Focus 独有的能力。
Focus 选择的是目标节点,当前位置仍可以是另一个实体
假设当前在作品 C,观察里有博物馆 E。模型输出 FOCUS(N2),程序先将本轮 N2 解析成 E,然后读取 E 的详情。底层读取直接使用目标实体的邻接表,不要求先把 current_entity 改成 E。
flowchart LR
C["作品 C:当前位置"] -->|收藏于| E["博物馆 E:Focus 目标"]
E -->|位于| F["城市 F"]
E -->|馆长| D["人物 D"]
E -->|收藏| B["其他作品"]
图中若只有 C 的普通观察,模型最初主要看到 C 与 E 的连接。Focus(E) 可以进一步展示 E 与 F、D、其他作品的关系,但执行完仍位于 C。current_entity 是普通观察的中心,不是程序能够读取哪些实体的权限边界。
和 Expand 一样,Focus 也经过公共动作解析器;除了当前候选编号,代码还接受可匹配的实体名称。这不意味着 Focus 会替模型检索最有价值的节点:目标仍然由模型选定,函数只读取该目标的材料。
详情展开的范围与数量限制
普通观察先按目标实体合并连接,再根据问题相关分数排序并截取 Top-K。Focus 的 serialize_focus(entity, query) 则遍历目标的邻接记录,按“关系、相邻实体、方向”去重后输出,没有执行普通观察的相关性排序和 Top-K 截断。函数虽然接收 query,当前内部没有利用它筛选详情。
| 返回部分 | 当前实现 |
|---|---|
| 节点概况 | 实体名称、邻接记录数、图片计数、关系类型集合 |
| 邻接边 | 目标实体的全部去重邻接边,保留关系与方向 |
| 图片标题 | 每条展示边最多取 5 条,仍受标题长度限制 |
| 图片路径 | 由配置控制,默认不展示;开启时也限制显示数量 |
| 视觉材料 | 聚合目标邻接边的图片,返回场景统计及最多 3 份可用结构化描述 |
所以,完整指的是没有普通观察那层 Top-K 截断,并不表示无限读取所有材料。Focus 不会递归展开多跳,视觉内容也来自已经保存的描述,不是调用时重新观察原图。即使 E 周围有很多图片,返回的结构化描述仍然只覆盖其中一部分。
取数函数与动作执行分支的职责
真正生成文本的函数是 serialize_focus(self, entity, query)。主循环的 Focus 分支如下,代码省略了相邻的其他动作分支:
elif action.type == "FOCUS":
detail = self.serializer.serialize_focus(action.target, query)
rels = self._get_relations_between(current_entity, action.target)
memory.add_focus(
step_num, action.target, current_entity, rels, action.raw,
)
user_msg = STEP_MESSAGE_FOCUS.format(
detail=FOCUS_PREFIX + detail
)
这个分支没有给 current_entity 重新赋值,也没有调用 build_neighbor_map()。它把详情包装成下一条用户消息,再交给模型继续决策。系统提示中定义的 Focus 是一个动作接口;具体取数由序列化函数完成,状态变化由主循环与记忆函数负责。
| 状态 | Focus(E) 后 |
|---|---|
current_entity | 仍为 C |
当前候选映射 neighbor_map | 仍对应 C 的候选,不重建 |
已访问集合 explored | 不因 Focus 新增 E |
| 对话上下文 | 增加 E 的详细返回文本 |
| 动作记忆 | 增加在 C 处 Focus E 的记录 |
| 程序证据边 | 追加 C 与 E 之间查到的关系,不是返回详情中的全部边 |
最后一点会影响对“探索状态”的理解。模型读到 E 位于 F,不意味着记忆函数自动把 E—F 也写入证据集合。它首先存在于详情消息里,可以参与下一次模型判断,后续是否被完整保留还受历史压缩影响。候选经过筛选进入观察、实体被实际访问、关系被写入证据记忆,是三件不同的事。
Focus 详情里的 E1、E2 是边的展示标签,不是新建的动作候选编号。此时再次使用 N1、N2,仍然指向原先以 C 为中心的映射;如果要把 E 作为新的探索中心并使用它的邻居编号,需要执行 Expand。
Focus 与 Expand 的配合与重叠
两者可以按信息需求配合,但当前代码没有独立调度器。提示词把 Expand 描述为沿有希望的路径继续,把 Focus 描述为查看重要节点的详情后再决定。模型每轮结合已有状态选择其中一个,不存在固定的“先 Focus,再 Expand”程序流程。
一种可能的配合是:当前中心 C 有多个候选,模型尚不确定是否沿 E 继续,于是先 Focus(E)。如果 E 的详情足以支持答案,就直接 Stop;如果需要继续沿 E 操作,再 Expand(E);如果 E 不合适,则保留 C 的候选映射,继续查看别的候选。这是接口层面的教学示例,不是作者报告的真实轨迹。
flowchart TD
C["当前位置 C,候选含 E 和其他实体"] --> D["LLM 根据问题、观察与历史决策"]
D -->|已确定沿 E 继续| X["EXPAND(E):移动并刷新局部候选"]
D -->|先看 E 的更多材料| P["FOCUS(E):读取详情,位置仍为 C"]
P --> J["LLM 结合详情再次判断"]
J -->|需要沿 E 继续| X
J -->|E 不合适| O["选择其他已知目标或候选"]
J -->|证据足够| S["STOP:给出答案和证据"]
如果模型原本就确定下一步去 E,直接 Expand 即可。先 Focus 再 Expand 可能重复读取,并额外消耗一个动作步骤和后续模型调用。反过来,已经 Expand 到 E 后,普通观察可能遗漏详情中的某些边或属性;直接指定 E 为 Focus 目标,可以请求更完整的内容,但这仍由模型决定,并非自动补查。
也不能把“保留当前位置”夸大成不可替代的回退能力。Expand 后仍可以沿图返回,当前宽松的实体名解析甚至允许直接切换已知目标。Focus 与 Expand 的确存在相互替代的情况,是否值得保留两个动作,需要在相同预算下做删除 Focus 等对照;本文所查材料不足以证明它独立提高了准确率或效率。
两个动作的稳定区别
| 维度 | Expand(B) | Focus(B) |
|---|---|---|
| 目标选择 | 模型先选 B | 模型先选 B |
| 返回粒度 | B 的评分、排序、Top-K 后观察 | B 的更完整邻接信息及部分详细材料 |
| 是否读取邻域 | 是 | 是 |
| 当前位置 | 改成 B | 保持原值 |
| 候选编号 | 以 B 为中心重建 | 保留原中心的映射 |
| 对下一步的作用 | 为沿 B 继续行动提供新局部候选 | 给模型补充信息,再决定是否切换中心 |
评分筛选也不是在“合并记录时就把重要知识永久写进状态”。合并负责把同一目标的多条连接整理在一起,评分与截断负责控制可见范围,动作执行与记忆函数再更新相应记录。Expand 调用这套观察构造流程,初始观察也调用;Focus 则绕过普通 Top-K 输出目标详情。模型在这些返回信息上决定下一步,工具执行本身不保证已经完成问题理解、路径判断或事实核验。
3.3 状态:在多轮决策间传递已有发现
Expand 和 Focus 取得的信息,需要进入下一轮模型调用才能继续发挥作用。这里要分清程序保存了什么,以及这次请求实际向模型提供了什么。当前实现没有每轮重新展开完整已探索图,而是同时使用局部观察、消息历史、探索记忆和结构化摘要。[3]
论文说明了上下文的作用,具体维护方式来自源码
论文 §3.1 将推理历史表示为此前观察与动作的序列,§3.2 说明子图随探索增长,形成供后续推理使用的上下文;实验中也将 Subgraph Summarizer 作为一个逐步加入的组件。但所读版本没有完整给出消息格式、预算计算、摘要字段或最近消息的保留规则。[1]
因此,下面的“记忆文本、子图摘要和最近四条消息”是当前仓库的实现细节,不能全部归为论文已经明确规定的算法。源码中的摘要主要由程序格式化已有记录产生,没有额外调用一个模型阅读整段对话并重新归纳。
消息历史与探索记忆是两份不同的记录
messages 保存模型交互内容:系统提示、原始问题、初始观察、模型输出、动作返回以及后续提示。Focus 得到的完整详情先进入这里,下一次调用便可以使用它。
程序另外维护 ExplorationMemory,其字段包括:
| 字段 | 保存内容 |
|---|---|
path_records | 动作步骤、起止实体、关系名称、部分思考和视觉提示 |
evidence_edges | 执行动作时追加的关系记录 |
visual_findings | CrossModal 留下的短视觉发现 |
explored_entities | 已访问实体集合 |
可以把 messages 看成这道题的交互原文,把 ExplorationMemory 看成程序另外维护的一份行动记录。原文比较丰富,行动记录比较紧凑;但后者不是把前者全部抄了一遍。
继续跟着作品 C 这条线:在 C 处 Focus 博物馆 E,详情里出现了“E 位于 F”。模型下一次调用可以从这条消息读到城市 F;但记忆函数主要追加本次 Focus 记录及 C 与 E 之间的关系。E—F 首先存在于消息文本中,不保证已经进入 evidence_edges。如果只看程序证据列表,可能误以为模型从没看见 F;如果只看旧消息,又可能高估压缩后模型仍能读到多少内容。
于是,“本轮看见一个事实”“访问过一个实体”“程序将关系记为证据”“下一轮仍能看到原文”需要分别判断。程序记忆独立于模型上下文窗口存在,但只有被组织成提示文本时,模型才能读取相应内容。
动作记忆与子图摘要的两种视图
当前代码从同一份程序记忆生成两种文本。memory.to_text() 按动作列出经过,回答“哪一步,从哪里,对谁做了什么”。下面是教学构造,不是实验日志:
Step 1: [Work_C] --EXPAND(held_by)--> [Museum_E]
Step 2: [Museum_E] --FOCUS(located_in)--> [City_F]
对应情形是先从 C 移动到 E,再在 E 处 Focus F。Focus 不会把 F 加入已访问集合。build_subgraph_summary() 则按实体和证据关系整理,可能生成:
Explored 2 entities: Museum_E, Work_C
Found 2 relation edges:
Work_C --[held_by]--> Museum_E
Museum_E --[located_in]--> City_F
如果已有视觉发现,摘要再附上 Visual findings,只展示最近三条记录。关系行展示时会去重,但标题中的关系数量取的是原证据列表长度,存在重复时不一定等于实际显示行数。
两份文本的用途就容易区分了:沿动作行阅读,可以还原先去了哪里、又查看了谁;沿摘要阅读,可以较快找到已经记下的实体和关系。它们来自同一份程序记录,只是组织顺序不同。
因此,生成摘要不会自动弥补漏记的事实。假如 E—F 从未进入记忆,摘要函数不会回到旧对话里把它重新找出来;虽然函数接收 query,当前也没有按问题筛选相关关系。动作文本同样不会输出保存的全部 thought。它们能帮助压缩和回顾,却不是完整探索过程的无损备份。[3]
摘要不只在超预算时才出现
有两条独立路径会调用摘要函数。
一条是正常推理中的主动提醒:当 use_summary 开启、步骤达到第 4 步或之后,并且已记录证据边时,程序生成摘要,附到最后一条 user 消息,或新建一条 user 消息。这一步不以超预算为前提,也不会同时删除先前历史;之后持续满足条件时,每轮都可能追加更新后的摘要。
另一条是 _compress_messages() 的超预算处理。即使关闭正常的 use_summary 开关,压缩分支仍会自行生成子图摘要。因此,“不开启阶段性摘要”和“任何时候都不使用摘要”也不是一回事。
flowchart TD
M["本题 messages 与 ExplorationMemory"] --> S{"开启摘要,步数至少为 4,且有证据边?"}
S -->|是| A["将当前子图摘要加入消息"]
S -->|否| B["保留现有消息"]
A --> C{"估算输入是否超预算?"}
B --> C
C -->|否| O["本次调用使用原消息列表"]
C -->|是| R["系统提示+记忆文本与摘要+最近消息"]
R --> T["若仍超预算,截短最近的长消息"]
T --> L["本次调用使用重组后的输入"]
超预算时的输入重组
默认上下文预算为估算的 24,000 token。_estimate_tokens() 将每条消息的字符数整除以三后求和,不使用精确 tokenizer,也没有完整计入消息协议开销。
未超预算时直接返回原 messages。超预算时保留最初的系统消息,把动作记忆文本与子图摘要合成一条新的 user 消息,再接上最近四条消息。对很短的消息列表,代码会取系统提示之外的已有消息。通常的重组形式为:
| 顺序 | 消息角色与内容 |
|---|---|
| 1 | 原始 system:Agent 角色、动作协议等基础说明 |
| 2 | 新 user:Exploration memory 动作文本,加 Subgraph summary 实体、关系和视觉发现 |
| 3 起 | 最近四条消息,保持原有顺序 |
这里的“四条”是消息条目,不是四个动作或四轮完整对话。新增的记忆与摘要都属于 user 消息,也不是两条新的 system 指令。
如果重组后仍超过预算,程序只遍历这些近期消息,将长度超过 3,000 字符的内容截为前 3,000 字符,再加截断标记。它不会继续压缩新建的记忆消息,也没有循环缩减到预算以内。因此这是一种尽力缩短输入的处理,不是严格的长度保证。
主循环把这个返回值赋给 call_msgs 再调用模型,没有直接用它替换整个历史 messages。也就是说,旧历史可能仍留在程序列表里,只是不再完整进入本次请求。需要再留意一个实现细节:近期消息对象是从原列表引用过来的,截断时修改其 content,可能同时截短原列表中的对应条目。不能简单理解成“每次压缩都将全部旧历史永久改写为摘要”,也不能说它完全不改变历史对象。
压缩后的信息保留与丢失
这套处理优先保留已记录的动作路径、证据关系、访问实体和近期消息。旧观察中没选中的候选、Focus 详情中的全部关系、完整标题与图片描述,以及早期模型推理,不会自动被重新提炼进摘要。
视觉发现本身也经过字符截取,不保证关键属性恰好位于保留部分。主观察中的关系方向与摘要中的方向是否一致,又取决于记忆如何记录,具体的方向问题见 §7.2。结构化输出可检查,并不代表压缩后的图语义一定保真。
还有一个容易忽略的风险:保留系统提示不等于保留原问题。原问题最初在初始 user 消息中;压缩函数没有单独重新插入 query,摘要函数也不使用它。近期观察或反思消息可能再次携带问题,但不能保证每次压缩后都有。初始消息里的路由提示和跨题经验建议同样没有专门的保留位置。
对于前面的问答,若 E—F 只出现在很早的 Focus 返回中,没有进入证据记忆,而那条消息又退出了本次输入,模型就未必还能完整利用这项发现。相反,实体已经写进程序访问集合,并不表示模型还保留着当时读到的详细材料。
因此,这里的“状态延续”由消息保留与结构化记忆共同实现。判断下一轮能否接上前一轮,需要检查实际装配的 call_msgs,而不能只看后台是否还存着一份图和一份历史。以上机制说明代码怎样传递信息,并未证明当前压缩足以保留所有回答所需证据。
4. 模块二:关系视觉对齐与选择性核验
模块一建立了探索接口,模块二说明视觉材料怎样进入同一个循环。这一章先讲可复用的视觉材料,再讲基础路由与 RVA 训练,然后展开 CrossModal 的取材范围和状态变化,最后核对停止规则与论文的置信度门控。需要始终区分:程序用分数筛选候选,工具提供详细材料,主模型判断如何行动;论文声称的可靠性约束是否已经实现,还要单独看代码。
4.1 视觉材料:从图片到可复用观察
模块一中的一跳邻接记录可以同时包含目标实体、关系、方向和图片引用。关系本身已经存在于图谱中,例如“歌手 A 参加音乐节 B”;图片是这条连接附带的材料,不是图片把两个实体连接起来。只有文字关系、没有图片的连接,仍然可以进入探索。
图片引用也不意味着图谱必须另建一个“图片节点”。在这里,它首先是查询视觉特征的键:邻接记录保存图片标识,运行时据此查找向量、场景标签和描述。图片内容可以独立处理,但哪些图片属于哪条边,仍由图谱关联记录决定。
预处理做一次,不同问题复用
源码中的主 Agent 主要消费文字化视觉材料。图片先经过离线处理:使用已有 CLIP 的图文对齐能力提取向量、匹配场景标签,再使用 InternVL2.5 生成结构化描述。这里没有重新训练 CLIP。只要图片和处理方式不变,结果可以跨题目复用,不局限于一套题;运行时的 VisualStore 是统一的加载与查询层,并非每张图片一个独立的 Store。[5][6]
| 代码编号 | 视觉表示 | 内容及用途 |
|---|---|---|
| L1 | CLIP 全局图像向量 | 默认 ViT-L/14 对应 768 维向量,供相似度计算使用 |
| L2 | VLM 结构化描述 | 对象、属性、空间或交互关系,以及一句话 caption |
| L3 | CLIP 场景分类标签 | 在预设类别中匹配“音乐演出”“肖像”“体育赛事”等场景 |
L1、L2、L3 是代码中的表示编号,不是必须依次执行的 Agent 阶段,也不表示 L3 比 L2 更详细。后文直接使用表示名称,避免把它们误读成流水线层级。场景标签只说明画面接近哪个预设类别,不能据此确认具体事件身份。
一张作品图片经过处理后,可以同时留下三种不同材料:向量供程序计算相似度;场景标签给出粗略类别;结构化描述让模型读到对象、颜色和相互关系。它们不是三张不同图片,也不是 Agent 必须走完的三个步骤。同一个图片标识把这些表示对应起来,运行时再按用途取用。
结构化描述里,objects 记录可见对象,attributes 记录颜色、服装等属性,relations 记录对象之间的互动,caption 用一句话概括画面。例如“人拿着麦克风”是画面内部关系,知识图谱中的“参加某音乐节”则是现实实体之间的关系。图片能描述前者,不表示已经确认后者。
回到红色帆船的例子:图像描述可以帮助判断作品是否满足题目的视觉条件,但“作品收藏于哪家博物馆”仍主要来自图谱记录。视觉材料与图谱事实各自补上一部分证据,不能因为图片挂在收藏关系旁边,就认为图片本身证明了收藏关系。[3][5][6]
flowchart TD
A["图谱边关联的图片"] --> B["离线视觉预处理"]
B --> C["CLIP 图像向量"]
B --> D["CLIP 场景标签"]
B --> E["VLM 结构化描述"]
C --> F["VisualStore 按图片标识查询"]
D --> F
E --> F
F --> G["向量参与候选评分"]
F --> H["文字摘要进入普通观察"]
F --> I["Focus / CrossModal 展开详细文字"]
实现中,image_keys.json 负责图片标识与向量行号的对应,embeddings.npy 保存向量,scene_labels.json 保存标签,descriptions.jsonl 保存描述。普通观察只取简短场景、caption 或对象及部分场景关系;Focus 和 CrossModal 可以展开更多内容,取材范围与动作职责仍按模块一的接口区分。[3][5]
这也解释了“选择性”的范围。路由阶段已经允许视觉评分,普通观察也可能带视觉摘要;CrossModal 通常是按需展开已有结果,并非此时才首次处理原图。它主要增加进入上下文的材料量,视觉模型的描述成本发生在预处理阶段。RVA 的某些推理入口还会重新编码图片,不能将这部分成本一起忽略。
预处理也带来信息边界:若模型漏掉了画面中的小徽章,后续展开缓存描述未必能补回这个细节;描述本身也可能有错。材料缺失时,代码可能退回场景标签或图片标题。标题属于文本元数据,不能与从像素识别出的内容混为一谈。[3][5]
4.2 路由:结合视觉信息筛选一跳候选
先明确评分对象:当前中心的一跳候选
程序读取当前中心的一跳邻接记录,按目标实体合并,对合并候选评分,再默认展示前 20 个。这里决定的是 Agent 本轮能看见谁,模型随后才选择动作;最高分候选不会被自动选为下一步。
假设当前中心是 A,A 与 B 之间有两条关系,分别关联图片①②和③;B 与 C 的连接还有图片④⑤。给 B 这个候选计算视觉分时,只收集 A—B 两条记录中的①②③,不包含 B—C 的④⑤。因此,这是当前中心 A 下的候选分数,不是 B 在整个图谱中固定不变的视觉分。
图片聚合与视觉相似度
对于收集到的图片,程序仅保留能在特征索引中找到向量的条目。“有效”在这里表示特征可查询,不表示图片已通过问题相关性筛选。设这些向量为 ,问题的 CLIP 文本向量为 ,代码先求均值,再归一化并计算余弦相似度:[5]
为什么这里要平均?对程序来说,一个候选可能附带一张图,也可能附带很多张图,但排序需要每个候选最终有一个数值。把向量求均值,是将数量不定的图片压成一个固定长度表示的简单办法;它没有在这一步选出最相关的单张图片。
例如,一组图片大多是演出现场,平均表示可能仍偏向演出;但如果题目的关键红色衣服只在其中一张出现,其余图片都不包含这个细节,均值也可能把这条线索冲淡。这是从聚合方式推导的限制,不是论文已验证的效果对照。先平均向量再归一化,也不等于把每张图片的相似度直接平均。
所以,这个分数更适合帮助程序先展示值得看的候选,而不是替模型确认所有视觉条件。之后展开具体描述,才有机会检查整体相似度没有说明白的细节。
基础启发式分的组成
论文用文本分与视觉分的加权和描述路由:
公开实现的基础排序并非直接照搬这个公式。其 text_score 更适合称为基础启发式分,因为它既包含字符串匹配,也包含图片数量和连接数量奖励:[3]
| 项目 | 实际判断 | 加分 |
|---|---|---|
| 目标名称 | 将下划线换成空格并转为小写,检查是否作为子串出现在问题里 | 命中加 100 |
| 关系名称 | 对这些连接的关系名做相同处理,任一命中即成立 | 加 50,只加一次 |
| 图片数量 | 合并记录的图片数量之和,不是可用向量数量,也未在此去重 | 每条加 0.5,最高 25 |
| 目标度数 | 目标实体的邻接记录数量,不一定是不同邻居数量 | 每条加 0.1,最高 10 |
这一段没有使用文本 embedding 计算语义相关性,也没有调用 LLM 打分。例如,一个虚构候选名称未命中、关系名命中,图片数为 6、目标邻接记录为 20,那么 。程序用这些简单规则筛选展示对象,Agent 再阅读展示出来的实体、关系、方向和证据进行判断。
关键词检测与视觉权重
最终基础排序为:
这里有两次不同的“看问题”,容易混在一起。CLIP 编码整个问题,得到问题向量,用它计算图片与题意的相近程度;关键词检测器则只统计预设视觉词命中了几个,用来调整这个相似度在总分中的分量。
每个关键词按是否出现计一次,不是视觉词占整道题的比例。比如出现 color、wear 一类词时,规则会提高视觉权重,但它没有理解中文同义表达的能力。换句话说,CLIP 负责算“有多像”,关键词规则负责决定“排序时多重视这个像不像”。存在问题文本时,权重范围为 0.1 到 0.5;没有问题文本时,代码设为 0。[3][5]
候选没有图片、没有可查询图片特征或没有问题向量时,基础视觉分为 0,仍可依靠其余基础分参与排序。无图片不是视觉否定;有图片也不等于视觉支持关系成立。图片数和度数奖励还会偏向材料丰富的节点,这是一种启发式取舍,而不是经过校准的正确率。
上述是基础 CLIP 路由。它比较问题与图片,未直接把完整三元组作为视觉匹配输入。RVA 才进一步尝试学习关系上下文与图片的匹配;它是可以另外接入的评分器,不能认为默认基础排序已经执行了 RVA。下面进入这一部分。
4.3 RVA:关系与图片匹配的训练过程
从问题相关性到关系上下文匹配
前面的 CLIP 分数已经能比较问题与图片,RVA 又增加了什么?继续设想一张两个人站在一起的合照:它可能与“人物、活动、合照”这些描述相近,但图谱记录的具体关系可能是配偶、队友或其他关系。只知道画面大致相关,还没有区分这些关系上下文。
RVA(Relation-Visual Alignment)尝试将头实体、关系、尾实体一起放到文本侧,与图片匹配。它的输入关注“这张图与 A—关系—B 是否匹配”,评分接口没有问题 Q 参数;“与当前问题是否相关”仍由原有排序承接。它也没有为每条边单独存一个待训练的权重,而是在许多样本上学习同一套匹配函数。[7][9]
这个任务比识别实体配图更细,但合照例子也说明了边界:照片里有两个人,未必能分辨他们是不是配偶。RVA 可以尝试学习关系线索,却不能凭一个高分完成事实鉴定。出生地关系也可能为真,而肖像图完全无法证明它;匹配不足不能直接推导关系不存在。
从原始关联构造正负样本
先不看网络,想一想模型从哪里获得“匹配”和“不匹配”的示范。当前代码从 ImgFact 图谱的已有记录出发:读取 ImgFact-Titles 和 Triplelist001,挑至少关联一张图片的三元组,保留原组合作正例,再保留图片、改动文本侧生成负例。[8]
以下以虚构实体 A、B、C 说明,一组图片 I 始终不变:
| 样本构造 | 输入组合 | 希望模型输出的标签 |
|---|---|---|
| 保留原始关联 | 1 | |
| 替换关系 | 0 | |
| 替换尾实体 | 0 |
关系替换在问:同样的人物与图片,换一个关系,模型能否区别?尾实体替换则让它尝试区分完整三元组,而不只是记住一个关系名称。这是样本设计的意图,不代表它一定学会了所期待的区分。
这样的标签来得方便,但信任来源仍是原图谱关联。正例没有人工逐图确认关系可见,替换后的关系也可能碰巧成立。因此它是带噪声的弱监督匹配任务,不能把 1 读作“图片已经证明事实”。这些噪声也解释了为什么降低训练损失,不足以证明模型具备可靠的关系核验能力。
入口最多采样 80,000 个正例。neg_per_pos=2 对关系替换和尾实体替换分别使用,所以每个正例最多四个负例,理论上限约 400,000 条;这是配置上限,不是本文核实过的实际训练规模。训练脚本打乱样本后按约 90%/10% 划分,没有按实体、图片分组,同源正负组合可能落在两侧。不能直接用这种验证说明对未见实体或图片的泛化。[7][8]
固定特征与可训练投影
有了边、图片和标签,下一步才是把它们变成可计算的输入。代码将边写成 head is relation of tail,使用固定 CLIP 文本编码器;图片侧复用预计算 CLIP 特征,对最多五张图片的可用向量求均值。两侧默认都是 768 维。[7][8]
如果到这里直接比较,仍然主要依靠通用 CLIP 空间。RVA 在两侧各接一个小网络,默认结构都是 768 → 256 → 256,中间使用 ReLU,参数彼此独立。它们将已有特征重新组合成适合这批匹配样本的表示。
“投影”并不表示手动找到一维叫配偶、另一维叫队友,再调整它们的权重。向量维度通常没有这样明确的人工含义。训练能改变的是这些数字怎样组合,以及原来相近的两个输入经过变换后是否仍然相近。
回看刚才的正负组合:图片侧可能相同,文本侧因关系或尾实体改变而不同。训练希望两侧投影后的正组合更接近、负组合更容易区分。CLIP 特征不反向更新,真正更新的是投影网络与温度参数,主 Agent 的参数也没有在这里训练。[7]
两侧输出归一化后得到余弦相似度 s,再计算:
sigmoid 将数值压到 0 到 1,温度参数通过正数缩放控制输出陡峭程度。正数缩放不会单独改变相似度的排序,而投影网络可以改变不同组合的相对次序。这个范围只说明输出形式,不意味着分数已经校准为事实概率。
单条标签与正负间隔的双重约束
现在假设模型给原组合打了 0.60,给替换关系后的组合打了 0.55。正例虽然更高,但负例也不低,而且只差 0.05。代码用两项损失同时约束这种输出。[7]
BCE 即二元交叉熵,逐条对照标签:正例目标为 1,负例目标为 0。正例给低分、负例给高分,都会受罚。公式写成:
Margin 额外关注相对距离,要求正例比负例至少高出间隔。单对损失为:
在默认间隔 0.3 下,刚才的教学分数产生 的损失。若正例为 0.85、负例为 0.20,已经相差足够远,这一对的 Margin 损失就为 0。BCE 已经能推动正高负低,Margin 再强调分离程度,两者并非毫无重叠。
实现中,Margin 比较的是一批样本里所有正分数和负分数的两两组合,不只比较原边与它派生的负例;对这些成对损失取平均。若批次缺少一类,此项为 0。最终损失是 BCE 加 0.5 倍 Margin,反向传播据此更新投影网络与温度。[7]
训练循环做的是在这些构造标签上调整匹配函数,不是 Agent 每次 Expand 时临时再训练一轮。后文主模型的 SFT 也会使用损失与反向传播,但训练对象和监督目标不同,不要混为一次训练。
推理分数接回候选排序
训练完成后,匹配器需要回到模块一的观察构造中才会影响动作。run_eval_rva.py 是一个明确启用它的入口:先算原有候选分,再计算 RVA、融合重排,最后生成器截取 Top-K。最高分仍不会自动成为模型必走的目标。[3][17]
设原有分数已包含启发式基础分与问题—图片相似度,该入口使用:
R 是 RVA 分数,默认融合权重为 0.2,代码中仍叫 alpha。本文用 beta 与前面按关键词调整的视觉权重区分。例如虚构候选 B 原分 60、RVA 为 0.40,C 原分 55、RVA 为 0.70,融合后分别为 56、58,C 才排到了前面。之后主模型还可以依据关系、历史和详细材料作出不同选择。
两种视觉分各有用途:基础 CLIP 看问题与图片,RVA 看关系上下文与图片。它们共享图片及特征来源,不能当作独立的两份证据。当前入口只是提供一种组合方式,是否启用需要检查运行配置,不能仅凭 Full 名称推断。
源码接入的粒度、数值与成本
把学习过程讲清后,再看接入代码的简化,才容易判断它们影响什么:
- 匹配对象的近似。 该评测入口聚合同一目标的图片,检查前五张缓存特征,却只取第一条关系;head 填当前中心、tail 填候选,没有按方向还原原三元组。多关系图片可能混用,反向关系也可能表达不准。因此它不是每条边都独立、完整核验。[17]
- 训练与推理的尺度。
score.py推理时移除训练温度,输出变成 sigmoid(s),余弦范围 [-1,1] 对应约 [0.269,0.731]。单独 RVA 的排序保持,但融合量级改变;乘以 100 只是缩放,不构成校准。[9] - 不同入口的计算成本。
score_from_emb()复用缓存向量,上述评测入口用它;score()重新从磁盘编码图片,另一接入函数取前三张图并调用它。不能将所有入口一概描述为没有在线图片编码。[9][17]
至此,RVA 补上的不是一个已经校准的关系真伪判断,而是一种从图谱关联样本学到的匹配信号。它先改变候选的可见性;关键视觉条件是否满足,还要结合下一节取回的具体材料来判断。
4.4 CrossModal:集中查看一组连接的视觉材料
一次 CrossModal 调用的起点与目标
模块一解决了如何选择动作,前面的评分解决了哪些候选优先展示。CrossModal 发生在模型已经看见候选之后:它请求更多材料,帮助判断某个候选是否满足问题的视觉条件。
以下使用一个虚构例子。当前中心 A 是音乐节,观察中 N2 对应歌手 B,模型输出 CROSS_MODAL(N2)。解析器将 N2 还原为 B,执行器调用:
# 示意参数,不是一次实际运行记录。
visual = serializer.serialize_cross_modal(
entity="歌手 B",
center="音乐节 A",
query=query,
)
这个函数接收当前中心、目标实体和问题,没有接收完整路径。它先在 A 的邻接记录中查找目标 B,找不到时再反向查找;两侧都没有连接才返回 No edge found 文本。因此它展开的是 A 与 B 之间的连接材料,不会自动检查从起点到 A 的全部路径,也不展开 B 到其他实体的关系。[3]
缓存材料的读取与详细输出
函数汇集这些连接的图片标识和标题,调用 VisualStore 读取预计算描述,再组织成文本。这里没有因为收到当前问题就重新调用 VLM 观察原图,也没有调用 RVA 产生新的核验分。[3][5]
| 输出部分 | 具体内容与范围 |
|---|---|
| 连接信息 | 相关记录的关系、方向和图片数 |
| 图片标题 | 每条连接最多取 8 条,属于文本元数据 |
| 场景概况 | 汇总关联图片的缓存场景标签,展示最多 3 类及出现次数 |
| 结构化描述 | 按输入顺序查找,最多展开 5 条有描述的图片记录 |
| 标题摘要 | 取若干已有标题拼接,并非针对问题新生成的推理结论 |
结构化描述包含图片标识、对象、属性、空间或交互关系及 caption;每张图也有限制,例如最多展开 3 个对象的属性、4 条场景关系。这里的五条不是选出的“五张最相关图片”,也没有先按问题对图片重排。query 虽然出现在函数参数中,当前函数内部并未使用它。[3][5]
下面是教学用的简化输出,旨在说明模型能读到什么,不代表真实数据或运行结果:
=== VISUAL INSPECTION: 音乐节 A <-> 歌手 B ===
Relation: 参加 | Direction: <-- | Images: 2
[FINE-GRAINED VISUAL ANALYSIS]
Image: example.jpg
Objects: person, microphone, stage
person: blue shirt, standing
[person] --holding--> [microphone]
Caption: A person in a blue shirt is performing on stage.
若问题要求红色衣服,模型可以据此怀疑 B 不符合条件。但“某张图描述为蓝衣服”还不等于已经证明 B 在所有相关图片中都没有穿红衣服;材料是否足够仍需判断。预处理漏掉的信息不能靠展开缓存自动补回。没有结构化描述时,可能只剩场景标签和标题;没有连接或图片时,工具也不会凭空补造视觉证据。
Focus 与 CrossModal 的取材范围
大白话说,Focus(B) 是翻开 B 的资料夹,CrossModal(B) 则是在当前 A 的位置,专门取出 A 与 B 之间的照片材料。两者使用同一个详细描述格式化函数,区别主要在取材范围和展示预算,而不是 CrossModal 具有更强的看图模型。[3][5]
flowchart LR
A["当前中心:音乐节 A"] ---|"图片①②"| B["目标:歌手 B"]
B ---|"图片③④"| C["其他活动 C"]
B ---|"图片⑤⑥"| D["其他活动 D"]
在上图中,Focus(B) 的视觉材料池覆盖 B 周围的①到⑥,CrossModal(B) 只取 A—B 的①②。两种动作都不会递归展开 C、D 的下一层关系。
| 对比 | Focus(B) | CrossModal(B),当前中心为 A |
|---|---|---|
| 想了解什么 | B 的整体资料和一跳关系 | A 与 B 之间的视觉材料 |
| 结构范围 | B 的节点概况、去重邻接记录 | A—B 的相关连接记录 |
| 图片来源 | B 全部邻接记录附带的图片 | A—B 连接附带的图片 |
| 每条连接的标题预算 | 最多 5 条 | 最多 8 条 |
| 结构化图片描述预算 | 最多 3 条 | 最多 5 条 |
| 是否重新用 VLM 看原图 | 否 | 否 |
| 是否依据 query 重选图片 | 否 | 否 |
因此,想找 B 还能连接到什么,适合 Focus;已锁定 A—B、想检查这段连接的画面,适合 CrossModal。这是对接口职责的理解,不是固定调度规则。Focus 的材料更杂且描述条数有限,关键图片可能未被展示;CrossModal 缩小材料池,有机会让目标连接的内容更集中。若 B 只有 A 这一个邻居且图片很少,两者输出可能高度重叠,不能认为它们在实现中完全解耦。
返回材料后的状态更新
CrossModal 不移动 current_entity,不把目标加入已探索实体集合,也不刷新当前 neighbor_map。详细结果被追加到消息历史,探索记忆记录一次动作,并保存截断文本作为视觉提示。[3]
flowchart TD
A["调用前:中心 A,候选映射中 N2 对应 B"] --> B["CrossModal 读取 A—B 的视觉材料"]
B --> C["详细文本追加到消息历史"]
B --> D["探索记忆记录动作与短提示"]
C --> E["下一次主模型调用"]
D --> E
E --> F["中心仍为 A,候选映射不变"]
F --> G["模型选择继续取证、移动或 Stop"]
主循环先截取返回文本前 120 个字符,记忆又将其中前 80 个字符加入视觉发现。这只是文本截断,不是语义提炼;提示可能包含标题栏而非关键视觉事实。完整描述主要留在消息历史中,后续压缩可能影响其保留,不能把“调用过 CrossModal”视为“全部视觉细节已永久进入记忆”。它也不因此增加关系证据边计数。[3]
4.5 从视觉材料到最终答案:判断与停止控制
视觉不符合时,不存在自动删除候选的分支
继续前面的虚构问题:题目要求红衣服,返回材料却描述蓝衣服。代码只是将这些信息交回模型,不会自动降低 B 的路由分、删除候选、将其加入否定集合或回退路径。模型可以继续查另一候选,也可能认为证据仍不足而继续取证。是否正确排除了 B,需要看后续实际动作,不能仅凭工具调用推断。[3]
由于调用后中心仍为 A,改查 A 的另一个邻居通常不需要先“退回 A”。只有之前已经执行 Expand 改变了中心,才涉及另行选择探索位置。这里也没有新增一个专门的自动回退工具。
工具输出不是结构化的通过/不通过结论。它增加的是可供判断的证据文本,主模型仍承担“是否满足问题约束”的判断。视觉提示鼓励在相关问题上取证,但没有强制所有视觉题必须调用 CrossModal,也没有因为两个候选分数接近就自动触发的阈值。[3]
Stop 分支的反思与结束处理
正常情况下,主模型输出 Stop、Answer 和 Evidence,解析器进入结束分支。代码没有要求此前必须调用过 CrossModal。此时的“停止”首先是模型的决策,不表示程序已经逐项验证了答案。[3]
当前 Stop 分支包含一次有条件的答案反思:答案为空、少于 5 个字符,或者恰好等于问题里某个引号括起的名称时,若尚未到最后一步,就再次提示模型。它是简单的可疑答案规则,不检查每个事实,也不判断视觉材料是否与答案冲突。短名称也可能是真实答案,因此这个触发器不是可靠的对错分类器。
还有一个实现边界:这次反思主要接受带答案的再次 Stop。如果模型改口提出 Expand,当前结束分支不会据此恢复探索并执行它。不能把“让模型反思一次”画成自动重新搜图的闭环。[3]
flowchart TD
A["主模型输出 Stop"] --> B{"触发可疑答案规则,且尚未到最后一步?"}
B -->|"是"| C["再调用模型,尝试修订答案"]
B -->|"否"| D["保留原答案"]
C --> E["仅接纳带答案的再次 Stop;否则保留原动作"]
D --> F["可选的拒答规则检查"]
E --> F
F --> G["返回答案或证据不足提示"]
拒答规则不是置信度计算
默认 AgentConfig 开启拒答,最低证据边数为 1。但这只是默认值,评测入口可以覆盖;通用 run_eval.py 的 Full 配置就关闭了拒答。[3][16]
开启时,_check_abstention() 的顺序是:若关系证据数量达到配置门槛,直接放行;否则,答案为空或包含 insufficient、no information 则拒答;再否则,证据数量为 0 才拒答。不能把它简化成“证据数量未达门槛一律拒答”,也不能理解成模型输出被事实验证器校验。
这个计数来自探索记忆的关系记录,可能包含重复,也不代表记录已经确认真实。CrossModal 保存的视觉文本不会直接增加它。默认情况下,一条记忆中的关系记录就足以绕过这项拒答检查,即使该关系不足以支持完整答案。这与检查整条路径的证据充分性差别很大。[3]
如果一直没有主动 Stop,动作步数耗尽后,程序要求模型基于已有材料给出最终回答,再做相应反思与拒答处理。默认 max_steps=8 是循环动作预算,Focus、CrossModal 也消耗步骤,不等于一定走了八条关系边。强制收尾只保证流程进入结束处理,不保证答案正确或证据完整。[3]
论文的 0.8 门控,不能当作上述代码已经执行的机制
论文第 3.3、3.5 节进一步提出路径级置信度:
按论文叙述,前者汇集路径上的文字、结构及路由支持,后者来自 CrossModal 的视觉核验。这个设计将“模型想结束”与“系统允许交付答案”分开,有明确的可靠性意图。[1]
但在本文固定版本的主循环中,CrossModal 返回文本,没有直接返回数值核验分;也没有看到明确的路径分数累积、二者相加和 0.8 阈值判断。实际可核对的是前面的反思及可选拒答规则。因此,这里只能记录论文声称的机制与代码落地之间的缺口,不能补写一个不存在的置信度计算过程。[3]
| 层次 | 论文描述 | 当前检查的主流程 |
|---|---|---|
| 核验范围 | 路径级视觉核验 | 展开当前中心与目标之间的材料,由模型结合历史判断 |
| 视觉核验输出 | 数值核验置信度 | 标题、场景、结构化描述等文本 |
| 最终放行条件 | 路由置信度加核验置信度,至少 0.8 | 模型 Stop,配合简单反思及可选拒答规则 |
公式本身也留有待解释处:路径分如何累积、两项是否同尺度、视觉重复计入如何处理,当前材料没有充分说明。不能把前面用于候选排序的分数直接代进来,那是不同层级、不同量级的数值。
4.6 回看模块二:信息增加,不等于判断已被保证
现在可以把这一章接回模块一:预处理形成可复用的视觉材料;基础 CLIP 和可选 RVA 影响一跳候选的可见性;普通观察、Focus 和 CrossModal 按不同范围展开文字;主 Agent 结合问题与历史选择后续动作。[3][5][9]
检查一条运行轨迹时,需要分别问:候选是否被评分阶段剪掉?关键图片是否进入详细输出?模型是否读懂了材料?停止规则是否真的执行了预期约束?这几种失败的位置不同,不能都归因于“视觉模型没看懂”。
模块二提供了评分和证据访问能力,最终的条件判断仍主要落在主模型上。若模型反复忽略视觉条件或过早停止,接下来的模块三会尝试从失败轨迹中提炼经验,改变后续题目的行为。这是后续策略改进,不是本次答案正确性的证明。
5. 模块三:轨迹反馈、经验适配与可选后训练
前两个模块处理一道题中的探索与取证,模块三关心做完之后留下什么:错误是否暴露了重复的行为问题?这些问题能否变成后续提醒?哪些轨迹可以成为训练示范?这里需要先分开提示级适配与参数训练,再分别追踪它们的输入、触发条件和产物。
5.1 论文的反馈思路与三条代码路径
论文第 3.4 节先收集成功与失败轨迹,评判最终答案,再分析失败模式、形成指导规则。它用两个抽象操作描述这一过程:[1]
第一式表示根据失败轨迹和错误分类体系形成指导;第二式表示利用轨迹与指导更新策略。这里没有规定唯一的学习算法。论文提到提示词修订、指导注入和 SFT 风格改进,后半段主要说明两种用途:经验以提示形式影响后续运行;正确与修正轨迹可导出为样本,支持可选离线训练。这不是必须顺次执行的三个阶段。
为了不把概念箭头当成已经接通的程序,要按入口看公开代码:
| 路径 | 入口与启动条件 | 完成后得到什么 | 是否更新参数 |
|---|---|---|---|
| A:逐题在线适配 | 显式运行 run_evolution_online.py | 下一题可使用的经验指导 | 否 |
| B:通用轨迹整理 | 显式调用 EvolutionEngine.run(),并开启 enabled | 轨迹、评估、诊断与样本文件 | 否,引擎不调用训练器 |
| C:Oracle 轨迹训练 | 单独运行 src/lora 的生成与训练脚本 | LoRA adapter,供后续加载评测 | 是 |
flowchart TD
A["路径 A:在线经验"] --> A1["每题回答、判分"]
A1 --> A2["判错后诊断,更新经验库"]
A2 --> A3["下一题注入提示,参数不变"]
B["路径 B:离线整理"] --> B1["收集、评估、诊断"]
B1 --> B2["构造并导出样本,到此结束"]
C["路径 C:独立 Oracle 训练"] --> C1["从已知图谱路径生成示范"]
C1 --> C2["训练 LoRA 参数"]
C2 --> C3["保存 adapter,另行评测"]
图中三条路径没有自动连接:在线错误累积到某个数量,不会自动触发训练;导出样本也不意味着参数已经更新。论文概括的迭代思想与仓库不同入口的具体行为,需要分别记录。[4][11][14]
5.2 结果判分与失败归因
在线反馈的数据入口与运行范围
run_evolution_online.py 默认读取 eval/benchmark_80.json,先按可选 ID 或题型过滤,再取前 max_samples 条,默认最多 30 题。探索后端是由 ImgFact-Titles 和 Triplelist001 加载的图谱,题目记录需要提供问题和 gold_answer。[11]
这里能确认的是脚本默认配置。当前检出的固定版本未找到被 Git 跟踪的 benchmark_80.json,因此无法据文件名核实实际题量、完整题目和构造历史;更不能把它直接等同于论文四个各 300 题的主评测设置。本文没有运行这条实验,也没有据此获得新的效果数据。
答案 Judge 与轨迹诊断器
继续用一道人物辨认题想象:标准答案是 C,Agent 却回答 B。对照答案可以先判出这题错了,但这还没有说明它为什么错。它可能选错起点,也可能找到了正确候选却漏看颜色,还可能看见了证据但提前结束。同一个错误答案,可能由不同过程造成。
因此代码将判分与归因分开。答案 Judge 只判断预测是否符合参考答案;在线入口默认用关键词,也可选择外部 Judge,默认模型配置名是 claude-sonnet-4-6。它主要接收问题、参考答案和预测,不是完整轨迹。调用另一个模型判卷,并不表示这个模型同时定位了错误步骤。[11]
失败后,TrajectoryAnalyzer 先执行预设规则;规则结果的置信度达到 0.8 就直接返回。否则,有配置诊断 LLM 才调用它;没配则保留已有规则结果或返回未知。这个在线入口没有注入诊断 LLM,默认走规则诊断。即使另配 LLM,也不必然是与主 Agent 不同的模型。[12]
预定义错误类型与结构化诊断
| 错误类型 | 想表达的问题 | 理解规则时要注意的边界 |
|---|---|---|
entity_mismatch | 实体不匹配 | 规则比较答案与参考答案中的名称,不足以证明起点识别错 |
relation_wrong | 关系选择不合适 | 依赖记录中的关系与问题词语匹配,不是完整语义核验 |
early_stop | 过早停止 | 少步骤、少证据只是启发式线索 |
visual_missed | 遗漏视觉核验 | 主要检查是否调用 CrossModal,不能覆盖所有视觉取证方式 |
hallucination | 答案缺少探索依据 | 主要依赖答案词语与已探索实体的比对 |
exploration_lost | 探索偏离 | 词语匹配比例不等于真实路径相关性 |
unknown | 无法确定 | 保留诊断不明的情况 |
这张表列的是预先定义的分类体系。诊断器会寻找符合规则的迹象,而不是从答案错了这件事自动推导原因。比如步骤不超过两步且证据边少于两条,规则怀疑过早停止;问题命中视觉词却没用 CrossModal,规则怀疑漏核验。[12]
放回人物辨认的例子:未调用 CrossModal 确实值得检查,但也可能已经通过 Focus 看到了足够描述。于是,“缺少某个动作”只能支持一个排查方向,不能直接证明缺少视觉信息。这就是诊断置信度与事实结论的区别。规则中的 0.85、0.9 等是程序赋值,不是经过校准的正确率;规则还会按顺序返回先命中的类型,并不列出所有可能原因。
诊断结果是一份结构化记录。以下是符合字段结构的教学示例,不是实际运行产物:
{
"trajectory_id": "example_001",
"error_type": "visual_missed",
"error_reason": "题目要求辨认服装颜色,但没有调用 CrossModal",
"confidence": 0.9,
"suggested_fix": "回答前查看相关连接的图像材料",
"key_failing_step": 3
}
原因、建议和失败步骤都是诊断结果,不应直接当作已验证的事实。例如 Focus 可能已经带回足够视觉描述,但规则只看到未用 CrossModal;在线轨迹转换也没有完整填充关系列表和证据边,进一步限制了诊断依据。若启用 LLM,当前诊断提示主要列动作、实体移动、关系摘要和答案,没有逐步展开完整观察与视觉材料。[11][12]
5.3 路径 A:从失败记录到后续指导
在线脚本每完成一道题就判分。判对只更新成功统计;判错则诊断,并把错误类型与建议交给 OnlineExperienceBank。默认建议主要来自规则模板,没有另一个模型在经验库里重新归纳所有历史。[11][13]
经验记录包含问题类型、错误类型、建议、出现次数和少量问题示例。去重键由“题型+错误类型+规范化建议”组成;同一组合再次出现才增加计数。下一题开始前,用英文关键词区分视觉、多跳、单跳,挑同类型里计数最高的三条,形成附加指导;没有匹配经验就不注入。[13]
跟着这条失败再走一步:诊断器给出“视觉问题回答前应检查相关图片”,经验库把它连同题型和错误类别存下。下一道题到来时,程序并不把旧题的整个子图重新交给模型,而是按新题类型选择这句提醒。模型看到的是新的问题、初始观察,以及附加的行为建议。
如果提醒重复出现,对应记录会增加 count;不是复制出许多完全相同的规则。取前三条时看的是 count,所以常见错误的建议更容易进入后续输入。例如颜色辨认题可能读到“先检查图像材料”,另一道单跳事实题则未必会读到它。题型本身由关键词规则判断,不是语义检索,也不能保证适用条件完全准确。[13]
这时发生的变化很具体:下一次模型调用多读了一段文字。模型可能据此主动调用工具,也可能仍然忽略;工具执行器没有因此增加“未核验禁止 Stop”的硬规则,模型参数也没有被更新。经验存在、经验进入提示、行为实际改变,需要分开确认。
“出现次数”统计同类诊断重复了多少次,不是建议被采用后成功了多少次。当前经验库没有逐条效果验证、冲突处理或自动淘汰机制;成功题也没有自动转成成功技巧。经验被生成、经验被注入、经验使动作改变,是需要分别核对的三件事。[13]
这条脚本使用每题的标准答案反馈后续题目,属于带反馈的顺序适应。它不是先在独立经验集学习、再冻结策略测试;题目顺序可能影响后面的提醒。这说明运行方式,不代表主实验已经采用了同一设置。
同一道题中的反思则是另一条机制:主 Agent 可以在配置步骤检查进展,或对可疑答案再想一次,使用的仍是主模型。它不因此自动进入跨题经验库,更不会自动启动 SFT。[3]
5.4 路径 B:导出样本不等于已经完成修正和训练
EvolutionEngine.run() 在开启配置后,按各阶段开关收集轨迹、评估答案、诊断失败,调用 SFTBuilder 并保存样本。评估器自身支持匹配、可选图谱查询或模型判分等模式,与在线脚本的 Judge 入口不同;名称相近不表示使用相同裁判。引擎最后返回整理结果,没有优化器或训练循环。[4]
构建器按以下条件分流:[15]
| 条件 | 样本标签 | 不能仅凭标签断言什么 |
|---|---|---|
| 轨迹被判正确 | positive | 不等于每一步都最优 |
| 失败,诊断置信度至少 0.7 | corrected | 不等于内容真的被修正 |
| 其余失败 | negative | 不等于已经构成可训练的偏好对 |
这里最容易把两个“确定”混在一起。假设诊断器以 0.9 的置信度判断某次失败漏了视觉核验,这表示它较有把握提出一个问题;但训练所需的是另一份材料:在哪个状态下查谁、工具返回了什么、接下来怎样回答。一个错误类别还没有提供这些动作和观察。
当前 corrected 分支没有真正生成并传入新的正确轨迹,缺少修正内容时仍从原轨迹拼装动作。于是样本可能在外层标成“已修正”,里面却还是原失败过程。这说明通用导出器停在了哪里,也说明为什么不能把诊断置信度直接当成正确示范的质量分。[15]
另一个容易混淆的地方是 label 一词:这里 positive/corrected/negative 是整条样本的元数据标签;后面训练张量里的 labels 是逐 token 的预测目标,两者不是同一概念。仅写一个 negative 字段不会自动让语言模型避免生成该内容。当前也不能据这个字段宣称已经执行 DPO 或强化学习。[15]
5.5 路径 C:从图谱已知路径构造 Oracle 示范
通用导出器没自动修好失败轨迹,不意味着仓库没有训练代码。src/lora 采用了另一种准备示范的办法:先知道图谱中该沿哪条路径取得答案,再把这条路径编排成 Agent 对话。这里的 Oracle 就是“构造时已知目标路径和答案”。[14][18]
因此,这条路线没有等待在线诊断给出“漏看图片”等建议。它直接从已知路径出发,准备输入观察、目标动作和答案。我们后面读 SFT 时,始终要记住这些示范的来历:它们是程序构造的教学过程,不是自动修正失败过程之后自然得到的产物。
图谱路径与训练问题的生成
sample_paths.py 从 ImgFact 图谱采样单跳、双跳和视觉类路径,再用模板生成问题。入口计划采样 3,000 条单跳、2,000 条多跳、2,000 条视觉样本;候选不足或后续过滤会减少数量,不能把 7,000 当成核实过的最终训练集规模。视觉类要求一定数量的图片和标题,目标描述主要由标题拼接,不能等同于逐图人工核验后的视觉答案。[18]
generate_trajectories.py 调用真实序列化器生成观察,模板生成 Thought 与动作。单跳通常是 Focus 后 Stop;双跳先 Expand 到中间实体,再 Focus 最终候选并 Stop;视觉类调用 CrossModal 后回答。这些是生成器编排的行为示范,不是主模型自主探索成功的日志。目标不在展示候选中时,相关样本会被跳过。[18]
flowchart LR
A["采样已知图谱路径"] --> B["模板生成问题与目标答案"]
B --> C["序列化器生成观察与工具返回"]
C --> D["已知路径编排示范动作"]
D --> E["多轮 messages 数据"]
E --> F["独立 LoRA 训练器"]
多轮训练示范的数据形式
下面是按单跳生成逻辑简化的虚构样本,文字不是生成器的原样输出;真实数据还包含模板 Thought、完整观察和证据:
{
"messages": [
{"role": "system", "content": "工具说明与动作协议"},
{"role": "user", "content": "问题与初始观察,N1 对应目标 B"},
{"role": "assistant", "content": "Action: FOCUS(N1)"},
{"role": "user", "content": "序列化器返回 B 的详情"},
{"role": "assistant", "content": "Action: STOP\nAnswer: B\nEvidence: ……"}
],
"query": "示例问题",
"answer": "B"
}
工具结果使用 user 消息承载,与这份 Agent 的文本协议一致。训练器读取 messages,而不是只读取一个最终答案。生成器当前构造序列化器时没有传入 VisualStore,因此这条默认生成路径的视觉内容不能假定与加载完整视觉特征的在线 Agent 一样。[18]
还有一处文件衔接必须留意:当前生成器输出 oracle_trajectories_v3.json,训练器默认读取 oracle_trajectories.json,流水线没有显式传 --data 对齐。训练代码存在,但默认脚本不能因此被当作已验证的一键闭环;实际训练读取了哪个文件,还需要配置和运行记录。[14][18]
5.6 SFT 训练:从一条示范到参数更新
读到这里,我们已经知道仓库能生成一份 Oracle 轨迹,也能启动 LoRA 训练。但“把轨迹拿去训练”仍然有些抽象:一段写着 Focus、Stop 的对话,究竟怎样改变模型?训练时工具还要再执行一遍吗?为什么代码既有 input_ids,又有 labels?
先不急着看配置表。我们沿着一条简化示范走一遍,等过程连起来,再回头对照脚本。下面的问题、实体和措辞都是教学示例;动作顺序参考上一节的单跳生成逻辑,不是实际实验记录。[14][18]
示范中的输入、动作与学习目标
假设问题是“A 的配偶是谁?”。初始观察已经显示,N1 对应 B,A 与 B 之间有 spouse 关系。生成器为这道题编排了一条示范:先 Focus(B),读取详情,再 Stop,回答 B。这里先 Focus 是生成器选择的示范模式,不代表每道单跳题都必须多查一次。
把工具说明和长观察暂时省略,对话看起来是这样的:
user:
A 的配偶是谁?
当前中心是 A。候选 N1 对应 B,连接关系是 spouse。
assistant:
Action: FOCUS(N1)
user:
这里是 B 的节点详情与关系记录……
assistant:
Action: STOP
Answer: B
Evidence: A --spouse--> B
注意,训练开始之前,示范动作和工具返回就已经写在文件里了。生成器知道图谱路径,用序列化器准备观察,再按模板写出动作;训练器读取这份对话,不会等模型在训练中“想到 Focus”,然后现场调用工具。[18]
我们想教模型的也不只是记住“A 的配偶是 B”。这条样本还在示范:看到这样的候选后,可以怎样输出合法动作;工具返回后,又怎样结束并给出答案。至于模型最后学到的是可迁移的行动方式,还是过度记住了模板,需要另外通过评测判断。
从多轮对话到 token 输入
对后端开发者来说,此时文件里还是熟悉的 messages:每条有 role 和 content。训练脚本先用聊天模板把它们拼成模型习惯的对话格式,再由 tokenizer 转成一串数字编号。[14]
token 是模型处理文字的小单位,不必恰好等于一个汉字或一个单词。例如 FOCUS(N1) 可能被拆成多个 token。具体怎样拆,要看实际 tokenizer;我们不用先背编号,只需要知道原来的文字已经有了一份数字表示。
这串编号在代码里叫 input_ids。如果把不同长度的对话组成一个批次,还要补齐长度,并用 attention_mask 标记哪些位置是真实内容、哪些位置只是补齐。它们最终被组织成张量。这里可以先把张量当成有形状的数字数组,不必为了读这段代码先研究高维数学。
到这一步,模型已经有东西可读了。但它还不知道:这段对话里哪些内容是条件,哪些内容是我们希望它学着输出的目标。这就是 labels 要解决的问题。
用 labels 区分上下文与学习目标
回到刚才的对话。用户的问题和 B 的详情,必须让模型看到,否则它没有依据选择动作;但训练时,我们不希望它把用户问题或工具结果也当成自己的回复生成。
我们希望它学的是两段 assistant 内容:先输出 Focus,之后输出 Stop、答案和证据。于是同一条对话需要两份不同职责的记录:input_ids 保留整段内容,labels 标记哪些位置需要算作学习目标。
| 对话中的内容 | 模型可以读取吗? | 是否作为这条示范的预测目标? |
|---|---|---|
| 工具说明、用户问题、初始观察 | 可以 | 否 |
Action: FOCUS(N1) | 预测后续内容时可读取前面的部分 | 是 |
| Focus 返回的详情 | 可以 | 否 |
| Stop、答案与证据 | 预测后续内容时可读取前面的部分 | 是 |
当前训练器把不参与目标损失的位置设成 -100;需要学习的 assistant 内容位置,则填入对应 token 编号。-100 是损失计算中的“忽略这个目标位置”,不是删掉输入,更不是告诉模型“这里是错误答案”。[14]
为了看清区别,假设一小段对话只有六个位置:前三个位置是提示和回复前缀,后三个位置是示范回复。用完全虚构的编号表示:
input_ids = [11, 12, 21, 31, 32, 33]
labels = [-100, -100, -100, 31, 32, 33]
前三个编号还在 input_ids 里,模型仍然能利用它们。labels 只是在说:“不用学习把这几处提示生成出来,把训练重点放在后面的示范回复上。”这也说明,语言模型 SFT 的 labels 与 RVA 的 0/1 分类标签不是一回事:这里监督的是接下来应该生成哪些 token。
下一 token 预测与训练时的上下文
这个疑问很自然:训练文件都已经写着 FOCUS(N1) 了,模型还需要学什么?关键在于语言模型预测的是下一个 token。计算某个位置的预测时,它只能使用前面的内容,不能读取后面的标准答案。
先把实际分词放在一边,从文字上理解:当上下文停在 assistant 即将回复的地方,模型要预测回复的开头;沿着示范继续往后,它会在前面的正确 token 已给定的条件下,预测下一个 token。比如前缀逐渐包含 Action: 后,后续目标来自示范中的 Focus 动作。训练并不要求它先自由生成一整段动作序列,才开始打分。
这也解释了为什么同一段示范既出现在输入,又出现在目标中:前面已经给出的部分,是预测后续内容的条件;尚未给出的下一个 token,才是当前位置要预测的目标。代码将同长度的 input_ids 与 labels 交给因果语言模型,内部损失实现完成下一位置的对齐,并不是让每个位置原地复制自己。[14]
轮到第二段 assistant 时,道理相同。它的条件已经包括原问题、示范中的 Focus 动作,以及预先准备好的工具返回。模型学习在这样的前缀后输出 Stop 和答案。训练时有正确示范可以沿着走;真正推理时没有这些未来内容,模型必须自己逐步生成、调用工具,再读取新结果。这两个过程不能混在一起。
从预测偏差到参数更新
设想模型刚开始更倾向于直接 Stop,但这份示范的目标是 Focus。训练计算的不是“它最后有没有回答 B”这一项,而是示范回复各个 token 的预测概率:正确目标的概率越低,对应损失越大。
所以,“SFT 让模型见识错误”容易产生误会。它主要让模型学习准备好的示范;这里的错误,是当前预测与示范目标之间的偏差。原失败动作如果直接作为正向目标,它也可能被模型学进去。上一节 corrected 标签的缺口之所以重要,原因就在这里:文件外面写着“已修正”,不会改变文件里面实际让模型模仿的内容。
如果想把这件事写成公式,只需要记住一个目标:提高示范 token 在给定前文条件下的概率。设参与训练的目标位置为 M,就有:
读不熟公式也不影响继续理解。它表达的就是:在需要学习的位置上,模型越不倾向于给出示范中的下一个 token,损失越大。
有了损失,还没有发生参数修改。backward() 先计算梯度,告诉我们各个可训练参数怎样变化会影响损失;优化器随后按照梯度、学习率和自己的规则,真正更新参数。可以把代码的职责拆开看:
# 帮助理解职责的简化循环,不是仓库训练器的完整实现。
optimizer.zero_grad() # 清掉上一轮留下的梯度
outputs = model(**batch) # 根据这批输入计算预测与损失
outputs.loss.backward() # 计算梯度,暂时还没有更新参数
optimizer.step() # 根据梯度真正更新参数
用最简单的梯度下降举例:某个参数原来是 2.0,梯度是 0.3,学习率是 0.1,更新后是 1.97。这个教学算例只用于区分“算出方向”和“真的修改”;仓库用的是 AdamW,还会使用历史梯度统计等规则。[14]
这不是每次更新都保证当前样本立刻改对,更不是把某条边的权重直接改成正确答案。训练要在许多示范上反复计算和更新,学到的效果仍需要检验。
LoRA 的更新范围与 adapter
前面一直说“修改参数”,现在才需要追问:改的是主模型的全部权重吗?在这份训练器里,采用的是 LoRA:保留基座模型权重不动,在指定模块上接入少量可训练的低秩适配参数。[14]
对刚才的示范来说,输入、labels 和损失目标没有因为使用 LoRA 就变成另一套东西。变化在于,反向传播和优化器最终用来调整的是这些适配参数,而非把基座所有权重都重新训练一遍。它们影响的是模型处理输入、预测后续 token 的方式,不是给“A 的配偶是谁”增加一个独立查表项。
因此,SFT 和 LoRA 分别回答两个问题:SFT 说“照着这些示范学习”,LoRA 说“通过这部分参数来完成调整”。这里训练的是主 Agent 的语言模型,与模块二中训练 RVA 的投影网络又是不同对象。[7][14]
flowchart TD
A["一条已准备好的 Agent 示范"] --> B["分词:整段对话成为 input_ids"]
B --> C["构建 labels:指定 assistant 内容为学习目标"]
C --> D["模型预测下一 token,计算与示范的偏差"]
D --> E["反向传播计算梯度"]
E --> F["到更新步时,AdamW 修改 LoRA 参数"]
F --> G["继续处理其他示范"]
G --> H["验证后保存 adapter,供后续推理加载"]
训练结束后保存的 adapter 就包含这些适配参数及配置。之后使用对应基座加载 adapter,输入新问题,才进入我们熟悉的 Agent 推理循环:模型生成动作,程序执行工具,模型读取结果再继续。这个阶段没有示范答案供它沿着生成,也不会因为每答一道题就自动继续训练。
训练脚本的执行方式与实现限制
理解上述过程后,再读脚本中的数值就不会只剩一串名词。流水线选择 Qwen2.5-7B-Instruct,训练 3 个 epoch,意思是计划遍历训练数据三次。每个微批次处理 1 条样本,梯度累积 8 次才执行一次更新;因此 backward 与 step 不总是一一对应。实际循环还会缩放损失、裁剪梯度、调整学习率,再清空梯度,进入下一轮。[14]
其余配置包括学习率 2e-4、LoRA rank 16、alpha 32、最大长度 1536。rank 与 alpha 是适配器的配置,1536 限制一条对话保留的 token 长度。这些数值来自 run_pipeline.sh,并非通用最优设置;直接调用训练脚本时,部分默认值不同。第一次理解训练流程,不必先背下它们。
训练器把文件前 max(int(样本数 × 0.05), 50) 条用作验证,其余用于训练,每轮计算验证损失,改善时保存 adapter 与 tokenizer。验证在这里检查的是示范预测损失,不能直接替代完整 Agent 的问答评测。超长对话会被截断,原示范的最后一个回答也可能因此丢失,需要留意数据处理后的实际内容。[14]
最后再集中看实现限制,就容易分清它们影响的是哪一步:
- 学习目标怎样定位? 当前脚本先将 labels 全设为 -100,再用 assistant 内容的 token 前缀搜索回复位置,复制相应编号。它不是严格按角色边界标注;短回复会跳过,有效目标少于五个 token 的样本会丢弃。因此,上面展示的是希望实现的学习目标,具体样本有没有被准确标注,仍需检查处理结果。
- 验证集怎样隔离? 训练器直接按文件位置切分,没有在这里按实体或路径分组,不能仅凭验证损失说明对未见路径的泛化效果。
- 训练到底读了哪个文件? 生成器输出
oracle_trajectories_v3.json,训练器默认读取oracle_trajectories.json,默认流水线未显式对齐。前面的学习过程解释训练器如何工作,不代表这组默认命令已经被验证能无缝连接。[14][18]
回看这条示范,Focus、工具详情和 Stop 始终是一份提前准备的教学材料。训练器把它变成输入与目标,计算预测偏差,再通过 LoRA 参数调整模型。它并没有在这一步自动诊断失败、重新探索或创造正确答案。也正因为如此,示范由谁生成、内容是否正确,必须在进入训练器之前先说明白。
5.7 模块三的边界:反馈、示范与训练结果分开记录
现在再看几种“策略改变”,它们的更新对象并不相同:
| 做法 | 改变的对象 | 当前材料支持到哪里 |
|---|---|---|
| 提示词修订 | 长期行为说明 | 论文列为可用更新方式,不应补写自动改写算法 |
| 在线指导注入 | 当前输入中的附加经验 | 有题型匹配、计数、选择和注入代码 |
| 通用样本导出 | 训练候选数据 | 有导出器,但 corrected 内容并未保证修正 |
| Oracle-LoRA SFT | 模型适配参数 | 有生成器与训练器,文件衔接和实际运行版本仍需核对 |
论文里的自奖励是对结果形成反馈的来源,不能只因出现“奖励”二字,就把这套训练解释成强化学习。当前检查的 LoRA 训练目标是示范文本的语言模型损失,并没有在这个循环中通过环境奖励优化动作策略。奖励评判、错误诊断和监督训练,是三个不同环节。
本章能够说明:经验可以改变后续上下文,Oracle 示范可以用于参数训练;但没有证据把它们拼成一个自动反复运行、每轮都已验证变强的自进化闭环。诊断为什么可信、修正轨迹从何而来、哪个训练文件与主实验对应,仍保留为与论文复现直接相关的问题。本文不把这些缺口补成尚未实现的设计,也不把脚本配置当作已复现的效果。
6. 把三个模块拼回一次问答
现在回到画家 A 的问题。以下是一条为了说明模块配合而构造的可能路径,不是论文日志,也不表示模型必然按此顺序行动。设作品 B 的材料不能满足红色条件,作品 C 的详细描述能支持红色帆船,C 收藏于博物馆 E,E 位于城市 F。
起点处,模块一构造状态,模块二辅助候选排列。系统从问题定位画家 A,把作品 B、C 整理成候选。基础评分和可选 RVA 会影响谁先被展示;模型看到的是排序后的关系、图片计数和短视觉提示,尚未取得完整收藏路径。
模型先补足视觉证据。如果短描述还不够,它可以在 A 处对 C 调用 CrossModal。工具返回 A 与 C 关联图片的详细材料,位置不变。模型据此判断“红色帆船”是否得到支持。如果描述缺失或存在矛盾,就应继续查证,而不是把工具调用本身当作验证成功。
随后沿关系继续走。模型执行 EXPAND(C),当前位置变为作品 C,新观察显示收藏机构。它再扩展到博物馆 E,读取“位于城市 F”的关系。程序记忆逐步记录访问与证据,必要时把已经取得的关系组织成摘要。
结束时检查问题是否完整回答。此时路径支持作品、收藏机构和城市,视觉材料支持限定条件,模型可以选择 Stop,并给出城市 F 与证据链。论文还设计了最终置信度门控,但当前主入口不能据此假定已经执行数值放行;相关差异在下一节解释。
问答结束后,外层反馈才有机会参与。正确轨迹可被收集为后续数据;失败轨迹则可以被判分、诊断并产生建议。在线经验库把建议用于下一题,不会在这条路径结束时自动训练新权重。
这次回放也说明了 Focus 的位置:如果模型只想先查看 C 是否有收藏关系,可以选择 FOCUS(C);只有需要把中心移到 C 并继续使用它的邻居编号时,才执行 Expand。动作范围、模型当前位置和当前已知证据要分别跟踪。
7. 实验与实现边界:哪些结论已有支持?
前面已经建立了三个模块的工作方式。本节再集中看实验范围、可选入口和实现缺口,避免把作者的设计意图直接当成当前仓库已具备的全部能力。
7.1 主实验报告了整体领先,但并非所有子集领先
论文报告四个各含 300 题的评测集合,使用 Claude Sonnet 4.6 Judge Accuracy。作者称 GraMMA 与所比较的 LLM 基线共用 Qwen2.5-7B-Instruct,并保持相同输入预算和评测协议。ImgFact 来自人工种子、模型扩充和人工审核,其余三个集合从官方题目抽样并对齐题型比例。以下是作者报告,本文未复测。[1]
| 数据集 | GraMMA | 表中最强对比方法 | 差值 |
|---|---|---|---|
| ImgFact | 70.00% | 65.00% | +5.00 个百分点 |
| WebQA | 89.67% | 88.33% | +1.34 个百分点 |
| MarKG/MARS | 29.33% | 27.67% | +1.66 个百分点 |
| MMCoQA | 93.33% | 91.67% | +1.66 个百分点 |
四个总体分数均领先,但 MMCoQA 的多跳和视觉子集中存在更高的基线分数,MarKG/MARS 的总体准确率也仍只有 29.33%。每组 300 题时,约 1.67 个百分点对应五道题;没有多次运行和显著性检验,不能把小幅领先写成稳定优势。模型裁判分数也不等同于人工审核或精确匹配。
参数分析展示了视觉权重与 Top-K 的扫描。原图中,不同数据集偏好的 K 不同,曲线也不是单调改善。论文提醒,这里使用另一套自动重判协议,绝对分数不能直接与主表对齐。
![原论文 Fig. 2:上排为视觉权重,下排为 Top-K 扫描。来源为所读 GraMMA_final.pdf 第 12 页,保留原图;采用的自动重判协议与主表不可直接比较。[1]](/_astro/paper-fig2-sensitivity.Bigs6-Y__ZpJdLY.webp)
这类结果支持作者在所测设置中的整体比较,但没有单独回答方向字段、度数奖励、选择性核验和经验注入分别贡献多少。字段级对照、核验次数和回答覆盖率仍有价值,不能由模块级叠加消融代替。
7.2 模块一:可读摘要不一定保留图语义
局部观察正确读取入边与出边,但探索记忆把边按“出发实体、关系名、到达实体”保存,没有单独记录原方向。用现有函数构造原事实 A --created--> B,再模拟从 B 访问 A,摘要会写成 B --created--> A。这是不调用模型的函数级构造检查,说明遍历方向可能被写成关系方向,不是对论文分数影响的测量。[3]
压缩还会省略未选分支的完整候选,视觉记忆主要保存返回文本开头的一小段,关键属性可能位于后面。估算预算也没有保证不断增长的记忆严格落在上限内。序列化配置虽然有 max_total_tokens,在所查观察输出流程中没有看到对应的自动降级执行,不能只凭配置名认定已经生效。
7.3 模块二:评分接口与论文公式并不完全一致
训练后的 RVA 接口接收边文本与图片,不接收问题 。因此问题相关性主要来自基础路由,而不是 RVA 自身的条件化判断。可选接入函数将分数再组合为:
这里用 区分基础视觉意图权重。集成函数聚合同一目标的图片,采用第一个关系打分;处理入边时,也没有显式恢复原三元组头尾。它比“每条边分别对齐,再精细聚合”简单。[9]
训练和推理还有尺度差异。训练使用 ,推理版 RVAModelLite 去掉温度,使用 。固定表示的排序仍单调,但数值范围变窄,与其他分数相加时已不是训练时的尺度。这个分数不宜直接解释为校准概率。
成本依赖入口:score_from_emb() 能使用预计算向量,普通集成函数调用的 score() 会重新读取和编码图片,参数扫描脚本则额外加入了边级缓存。“能够缓存”和“默认路径已避免重复编码”需要分开确认。[9][10]
负样本也没有专门覆盖头尾互换、近似颜色冲突或视觉困难样本;随机替换没有完善排除假负例。生成后随机切分训练验证,也不能直接证明对未见实体或图片组的泛化。[8]
7.4 模块三:诊断、经验与微调还没有组成完整闭环
轨迹转换没有完整填充关系列表和证据边,诊断规则看到的证据数量可能少于实际探索记录。因此,“记录里边太少”不能直接归因为“Agent 查得太少”,依赖关系字段的检查也会受限。[12]
SFT 数据构建存在类似的阶段差异。当前 SFTBuilder 会将高置信度诊断的失败样本标为 corrected,但该分支没有真正生成并传入纠正后的轨迹。用构造的 EXPAND → STOP 失败样例检查,标签变成 corrected,动作仍为原失败序列。它不否定独立 LoRA 训练路径,但不能据此认定自动纠错数据已经准备好。[15]
在线经验使用当前题的参考答案判分,再影响后续题,属于带反馈的在线适应。题目顺序可能改变经验内容,不能不加区分地当作测试前已经冻结的策略。若要证明迁移,应隔离经验生成集与测试集;若评估在线适应,应说明反馈条件并比较不同顺序。目前还缺主表与具体运行配置的完整对应。
7.5 停止、置信度与 Full 配置需要分别核对
论文 §3.5 区分停止搜索和允许回答,提出:
但所查主 Agent 没有与之对应的完整路由置信度累积、视觉置信度返回和门控链。实际行为主要是模型 Stop、动作预算耗尽后的答案请求、同模型反思,以及可选的证据边数和答案文本拒答检查。代码中用于诊断或模糊匹配的其他 0.8,不能替代这条输出阈值。
路径分数如何累积、两项是否同尺度、视觉证据是否重复计入,因此仍缺明确答案。可以指出设计风险,但不能假设完整公式已经在主实验执行。
配置名也容易带来误解。通用 run_eval.py 的 plus_reflect 开启周期性反思,full_gramma 在此基础上主要开启更完整的视觉描述;它没有自动接入在线经验库、LoRA 或 RVA,并且关闭了拒答。因而不能把 Reflection → Full 的差值只归因于进化学习或置信度控制。[16]
该脚本以纳入汇总的问题数为分母,没有只保留非拒答样本来计算准确率,也未单独报告覆盖率。拒答文本仍进入判分,最终是否算错取决于参考答案和 Judge。
7.6 读完以后,哪些设计值得带走
GraMMA 提供了一种有用的组织方式:先明确模型的可见状态,再让工具按不同范围展开证据。程序排序控制候选可见性,模型负责在这些信息上行动,探索记忆则承接多轮发现。这使“候选被剪掉”和“模型在已有证据下选错”成为可以分别排查的问题。
视觉模块和反馈模块也有不同的验证责任。RVA 的标签和分数尺度决定匹配信号能解释到哪里;经验提示的诊断可靠性、适用条件与数据隔离决定它能否改善后续行为。把它们各自的输入、输出和更新位置说明白,比把所有机制合称为自进化更便于复现。
回到开头的问题,系统要交付的仍是一条满足视觉约束、连接到答案城市的证据路径。论文给出了围绕这件事组织 Agent 的框架,公开代码则让我们看到哪些接口已经具体化,哪些环节还需补足。理解这两个层面,就能既读懂方法,也不过早把一个设计箭头当作已经验证的能力。
资料与代码索引
本文引用代码均固定在提交 6fa01548eb7004be5a936da163c1295a5ad8a8e6,避免仓库后续更新改变对应关系。
-
论文:GraMMA: A Graph-Conditioned Multi-Modal Agent for Fine-Grained Knowledge Reasoning,用户提供的
GraMMA_final.pdf,主要对应第 3、4 节。仓库所附 camera-ready 版本另供对照,不作为同版替代。 -
项目入口与目录:README。
-
观察、动作、记忆:serializer.py、agent.py、prompts.py。
-
视觉特征与文字输出:visual_store.py。
-
图像结构化描述提取:extract_structured_desc.py。
-
RVA 网络与损失:train_rva.py。
-
RVA 标签和负样本:generate_training_data.py。
-
RVA 推理与接入:score.py。
-
带缓存的参数扫描:run_sweep.py。
-
在线评测与外部判分:run_evolution_online.py、eval_results.py。
-
错误诊断与轨迹转换:analyzer.py、trajectory.py。
-
在线经验选择与注入:online.py。
-
独立 LoRA 流水线:run_pipeline.sh、train_lora.py。
-
SFT 数据构建:sft_builder.py。
-
通用消融配置与统计:run_eval.py。
-
RVA 实例级排序接入:run_eval_rva.py。
-
Oracle 路径与示范生成:sample_paths.py、generate_trajectories.py。
