Appearance
编号资产管理(Agent Asset Index)
模块路径:
backend/harness/asset/适用范围:Chat / Image / Video / Omni 四个 Agent 共享。 本文是「编号资产管理」子系统的总览,聚焦核心类与核心流程,用图解建立 mental model。
一、它解决什么问题
Agent 在生图 / 生视频时,需要引用会话中的图片、视频、音频。如果让 LLM 直接在工具参数里填 URL,有三个顽疾:
| 顽疾 | 表现 |
|---|---|
| URL 幻觉 | LLM 编造不存在的 URL |
| URL 抄错 | 长 URL 容易抄错,历史上出现过裸编号 "1" 穿透到 Provider,触发 unknown url type: '/1' |
| 历史资产难引用 | 之前生成的图 URL 很长,LLM 记不住、对不上 |
解法:给每个多模态资产分配一个稳定短编号(img_1 / vid_2 / aud_1),LLM 只填编号,后端把编号解析成真实 URL。这样 LLM 永远不碰 URL,从根上消除幻觉与抄错。
二、两个核心概念
1. 编号体系
每类媒体用 ASCII 前缀,各自从 1 连续编号:
| 类型 | 编号格式 | 来源 |
|---|---|---|
| 图片 | img_1, img_2, … | 历史图 + 本轮参考图 + 首尾帧,统一连续编号 |
| 视频 | vid_1, vid_2, … | 本轮上传视频在前,历史生成视频接续 |
| 音频 | aud_1, aud_2, … | 本轮上传音频 |
三类图片(历史图、本轮参考图、首尾帧)共享同一个
img_N序列:历史图img_1..M→ 本轮参考图img_{M+1}..→ 首帧 / 尾帧接续img_N/img_{N+1}。这样 LLM 看到的是一套连续编号。
2. 两个 state 字段(务必区分)
| 字段 | 语义 | 生命周期 | 内容 |
|---|---|---|---|
state.assets | 资产池(有哪些资产) | 累积(有 reducer) | {asset_id: {url, type, description, created_at}} |
state.assets_index_map | 编号映射(此刻叫什么编号) | 每请求重建的派生映射(无累积) | {img_1: url, img_2: url, …} |
前者是「资产真相」,后者是「编号此刻的投影」。编号映射每轮重建,所以稳定它是整个子系统设计的核心难点。
三、核心类
按职责分成五组(对应后面五个流程环节):
| 环节 | 核心类 | 文件 |
|---|---|---|
| 提取入池 | MessageAssetExtractor / AssetCatalog | backend/harness/asset/catalog.py |
| 本轮素材视图 | CurrentRequestAssets | backend/harness/asset/current_request.py |
| 编号生成 | AssetIndexBuilder / AssetIndex / ids.py | backend/harness/asset/index/ |
| 写回 state | AssetStateUpdater | backend/harness/asset/state_update.py |
| 展示给 LLM | AssetPromptPresenter | backend/harness/asset/presentation/prompt_presenter.py |
| 解析回 URL | AssetReferenceResolver + resolve_reference_list | backend/harness/asset/index/resolver.py、reference_resolver.py |
| 数据模型 | AssetRecord | backend/harness/asset/models.py |
四、核心流程:五环节闭环
整个子系统是一条闭环,资产从「出现」到「被 Provider 使用」必经这五步:
下面逐环节展开。
4.1 提取入池
MessageAssetExtractor.extract 扫描消息历史,从三类消息里抠出图片资产:
关键约定:提取强依赖 COS URL 正则 + 图片判定。所有资产必须托管在 COS,这里才能识别。
python
# backend/harness/asset/catalog.py
COS_URL_PATTERN = re.compile(
r"https://[a-z0-9-]+\.cos\.[a-z0-9-]+\.myqcloud\.com/[^\s\)\]\"\',]+"
)
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"}
def is_image_url(url: str) -> bool:
path = url.split("?")[0].lower()
return any(path.endswith(ext) for ext in IMAGE_EXTENSIONS) or "/images/" in pathdescription 的智能关联:生图连续生成多张图时,AIMessage.content 常是过渡寒暄(「第一张完成!接着第二张」),无法区分单张。所以提取器先建 tool_call_id → 描述 映射,优先用生图工具的 prompt 参数(更具体),content 兜底,再通过 tool_call_id 关联回对应 ToolMessage。
4.2 编号生成(核心难点:保证稳定)
AssetIndexBuilder.images() 把历史图与本轮图合并成一条连续 img_N 序列:
为什么要「排除已在历史的 URL」?因为 context_node 每轮都会重建编号映射,必须保证第一轮和第 N 轮给同一张图编同一个号,否则 LLM 之前填的编号就错了。
python
# backend/harness/asset/index/builder.py
def images(self, *, existing_assets, request, max_assets) -> AssetIndex:
history = self.history_images(existing_assets, max_assets)
current = self.current_images(
request,
offset=len(history.entries),
exclude_urls=set(history.entries.values()), # ← 按 URL 去重
)
return history.merge(current)首尾帧不并入
assets(每轮由当前请求重新提供),其编号由AssetPromptPresenter.append_image_refs接续img_N。
4.3 写回 state
AssetStateUpdater 只做两件写回动作,不重复计算编号(编号由调用方算好传入,避免首入 / 回归不一致):
merge_current_request_images:把本轮 default 参考图并入state.assets池merge_index_maps:把调用方算好的各类编号映射整体写入state.assets_index_map(每请求覆盖)
4.4 展示给 LLM
AssetPromptPresenter 把编号渲染成文本片段拼进 system prompt,且编号与 assets_index_map 的 key 完全一致(它复用同一个 AssetIndexBuilder,而非自己另算):
[本次会话中的图片]
以下图片出现在本次会话中,需要参考时,请使用对应编号填入 reference_images 参数(不要使用 URL):
img_1. [历史图片1],一匹可爱的小马
img_2. [参考图片1]4.5 解析回 URL(三态校验)
resolve_reference_list 对 LLM 填的每一项逐项校验,无论 assets_index_map 是否为空:
python
# backend/harness/asset/index/reference_resolver.py
for ref in refs or []:
if is_passthrough_url(ref): # 合法 URL → 放行
resolved_urls.append(ref)
continue
url = resolver.resolve_single(ref) # 编号 → URL
if url is not None:
resolved_urls.append(url)
else:
invalid_refs.append(ref) # 非法 → 阻断「无论是否为空都逐项校验」这个约定,根治了历史 bug:早期 handler 在
assets_index_map为空时跳过解析,导致裸编号穿透到 Provider。 旧中文编号(如「图片1」)的兼容由backend/harness/asset/index/legacy_alias.py处理,属迁移期产物。
五、三个关键设计
设计 1:URL 去重 → 首入 / 回归编号一致
资产池对同一 URL 幂等(AssetCatalog.merge 按 known_urls 去重),所以无论提取多少次,池子内容稳定;编号基于「并入本轮图片后的 assets」一次性算好,AssetStateUpdater 不重算。这是编号稳定性的根基。
设计 2:三态校验 → 防裸编号穿透
解析端对每一项「合法 URL / 命中编号 / 非法」三分,非法直接阻断并回告可用编号,绝不穿透到 Provider。
设计 3:描述回填 → 修复历史脏数据
旧代码入库的图 description 全是泛化默认值(「历史生成的图片」),因 URL 去重永不更新。提取器开了一条特殊路径:当已有图的 description 属于泛化集合且能从 messages 重新关联到准确描述时,按 asset_id 回填。详见 2026-06-22-agent-asset-two-phase-description-backfill.md。
六、一次完整调用的生命周期
以「用户上传 1 张图 → LLM 调生视频工具引用它」为例:
工具返回的产物会再触发「提取入池」(
tool_node即时提取 +context_node全量重建),形成循环。
七、源码索引
| 关注点 | 文件 |
|---|---|
| 数据模型 / asset_id 生成 | backend/harness/asset/models.py |
| 资产池视图 + 消息提取 | backend/harness/asset/catalog.py |
| 本轮请求素材视图 | backend/harness/asset/current_request.py |
| 编号体系常量 / 前缀 | backend/harness/asset/index/ids.py |
| AssetIndex(编号→URL 映射) | backend/harness/asset/index/asset_index.py |
| 编号生成(连续编号 + 去重) | backend/harness/asset/index/builder.py |
| 编号→URL 解析 | backend/harness/asset/index/resolver.py |
| 参考列表解析(三态校验) | backend/harness/asset/index/reference_resolver.py |
| 旧中文编号兼容 | backend/harness/asset/index/legacy_alias.py |
| 写回 state | backend/harness/asset/state_update.py |
| 渲染进 prompt | backend/harness/asset/presentation/prompt_presenter.py |
| 每轮重建入口 | backend/harness/context/node.py |
| 即时提取(本轮工具返回后) | backend/harness/agent/common/tool_node.py |
八、相关文档
- 2026-06-22-agent-asset-two-phase-description-backfill.md:描述两阶段回填(本文「设计 3」的专题)
- 2026-06-22-agent-asset-legacy-alias-removal.md:旧中文编号别名下线
- 2026-06-16-image-sandbox-artifact-as-reference.md:沙箱产物作为参考图(资产来源之一)