M20 · 图搜选购:上传参考图,找同类相似品#
背景#
用户想买东西时,最自然的表达往往不是一段文字,而是「就这个,给我找类似的」——手机里存着一张
截图、一张街拍、一个别人晒的物件。在此之前 ShoppingX 只吃文字:POST /api/upload 这个接口早
就存在(会话目录、路径穿越防护都齐),但上传的图落盘之后无人消费,是个孤儿接口;前端连
上传入口都没有。这个里程碑把这条断头路接通。
核心约束:主模型不认图#
LLM_MAIN 是纯文本模型(deepseek 系)。这一条决定了整个架构走向——图片不能进主 loop 的
messages,塞进去只会报错或被静默忽略。
于是有了本里程碑最关键的一个设计:图被关在工具里。新增第 10 个工具 image_understand,
它内部调一个多模态模型(LLM_VISION,走同一个 DashScope 供应商的 qwen-vl-plus,零新增
key),把图读成结构化字段——品类、颜色、材质、风格、显著属性、建议检索词——然后主 loop 只
看到它吐出的文本结论,从头到尾没见过那张图。
这个「关起来」的副作用恰好是好的:它天然符合项目既有的「工具中间产物不污染主上下文」原则。 一张几百 KB 的图不会挤进后续每一轮的 prompt,也不会打断 prompt cache 前缀。
关键决策一:为什么不做真正的「以图搜图」#
诚实标注:这不是以图搜图,是「看图说话再搜文本」。
真正的跨模态检索要给商品库的 138 万条商品全部下图、做 CLIP 图像编码、建一个图向量库。三个理由 让它在本项目里不可行:商品图是 Amazon CDN 外链(会挂、会限流、会 403);几百 GB 存储;没有 GPU, CPU 编码 138 万张图不是「慢」而是「跑不完」。
所以走的是:VL 模型把图翻译成英文检索词 → 并入既有的 BGE-M3 文本召回链路 → 零改 Qdrant、零改 召回库。代价是它给的是同品类同属性的相似品,不是同款复刻。对「想买类似这种风格的东西」这个 真实场景足够;「找到一模一样的那只包」做不到,也不宣称。
(留了后路:真要做视觉重排,可以只对文本召回的 top-100 候选现场下图、算视觉相似度,插在现有 reranker 之后。100 张图是秒级的,这才是以图搜图在本项目里能落地的形态。)
关键决策二:看图必须先于 planner,而且不由模型决定#
这是实现中途推翻重来的一处。
最初的设计是把 image_understand 当成普通工具注册进去,让主 loop 自己决定调不调——符合
AgentLoop「不写死链路」的范式。但读代码时发现:planner 是 Harness 在开局确定性预跑的
(abefore_agent,不占模型往返)。这意味着如果看图晚于 planner,用户「只发一张图 + 想买这个」
的场景里,planner 会在没看到图的情况下拆结构化字段——拆出一片空白:没品类、没预算、没约束,
后面全链路跟着空转。
所以改成:有图时,Harness 在 planner 之前先把图看掉,再把结论并进 planner 的 intent。 论证与 planner 预跑本身完全同构——「用户传了图要不要看」根本不需要模型决策(有图就得看,没图 就不看),不该为它付一次模型往返;而顺序错了会让下游全盘空转。
预跑产出的消息形状与「模型真调了一次工具」逐字同构(AIMessage(tool_call) + ToolMessage),
所以主 loop 读它和读任何工具结果没有区别,AGUI 事件也照常上报(前端能看见「正在看图」这一步)。
实测的调用链:image_understand → planner → item_search → price_compare → item_picker → shopping_summary。
踩过的坑#
1. 外链图不能交给 provider 去下载。 最早想省事,直接把商品的 img_link 当 image_url 传给
DashScope,实测被 Amazon CDN 403 挡下(Failed to download multimodal content)。改成服务端读盘
转 base64 直传——何况用户上传的图本来就只在我们盘上,压根没有公网 URL 可给。
2. fork 子 Agent 找不到图。 上传目录是 uploaded/<thread_id>/,但 fork 出的子 Agent 有自己
的 thread_id,uploaded/<子thread_id>/ 根本不存在。正确做法是按 session_dir 定位——它是
继承父的(项目既有约定),从它反推会话属主,主/子 loop 才能读到同一份图。
3. VL 调用漏账。 code-review 抓出来的:项目里每个内部调 LLM 的工具(planner /
chat_fallback / shopping_summary)都挂 usage callback 并 charge_tool_llm_usage,因为工具内部的
调用不经过 agent middleware 的记账路径。新工具漏了这一笔——一张图上千 prompt token,成本闸
和 M19 的用户每日 credit 配额会完全看不到这笔开销。
4. 「只发图不打字」这条最自然的路径反而走不通。 前端允许空文字带图发送,但 hook 的
startTask 开头有一道 if (!query.trim()) return 的空 query 守卫——点发送后界面毫无反应。修法是
放行「有图无字」,并补一句默认意图(否则 planner 拿到空串、会话标题也是空的)。
5. 阶段机白名单漏登记 = 工具被永久拒绝。 项目有一条测试专门断言「阶段表的并集必须覆盖
FULL_TOOL_SET」,新工具没进 PLANNING 白名单时它立刻红了。这条测试的存在本身就是个好设计。
安全边界#
上传口原本只有大小上限 + 路径穿越防护,没有类型校验。既然这些字节现在会被转 base64 送进 VL 模型,就在入口按 magic bytes 认图——不认扩展名(改个名就绕过),不认 Content-Type(客户端 随便填)。大小上限与工具侧读同一个 env,避免出现「传得上去却看不了」的中间地带。
面试可讲点#
- 模型能力异构时,怎么让一个纯文本 Agent 具备视觉能力:不是换模型,是把视觉关进工具边界里, 让它降维成文本结论再进主上下文。顺带解决了多模态上下文膨胀与 prompt cache 失效。
- 哪些决策该交给模型,哪些该由机制确定性做掉:「有图要不要看」不是决策,是事实——它和 planner 一样属于确定性预跑,为它付一次模型往返是纯浪费,而且顺序错了会让下游空转。
- 诚实的能力边界:能做「相似品」,不能做「同款」,并且知道为什么(没有 GPU、外链图不可靠), 以及如果要做该怎么做(两段式视觉重排)。
- 可能的追问:为什么不用 CLIP?→ 见「关键决策一」的三个理由,以及 top-100 两段式的折中方案。