Memoh 的 Bot 使用 LLM 作为智能核心。随着 DeepSeek 等性价比极高但仅支持文本的模型流行,两个问题浮出水面:
- 多模态鸿沟:用户发图片,Bot 看不到——图片被路由系统丢弃,模型收到一条"空"消息
- 成本浪费:后台任务(心跳分析、上下文压缩、标题生成)都用主模型,低频但累积的 token 费不可忽视
传统解法是"用户自己选支持视觉的模型"——但这限制了模型选择。我们需要一种模型无关的消息处理管线,在消息到达 LLM 之前自动转换内容。
架构全景
用户消息(文本+图片)
│
▼
resolve() — 解析上下文、加载历史
│
▼
prepareRunConfig()
│
├── 生成系统 prompt
│
├── MessagePipeline(核心)
│ ├── ImageProcessor — 描述图片(当前)
│ ├── AudioProcessor — 转录音频(未来)
│ └── FileProcessor — 分析文件(未来)
│
├── 追加 Query + 处理后内容到 Messages
│
▼
agent.Generate() → LLM 看到纯文本
三层模型体系
ChatModelID (主模型)
↑
HeartbeatModelID (预算模型 / 小型模型)
↑ ↑ ↑ ↑
Compaction Title Heartbeat Vision
(各自可覆盖)
核心子系统
1. Budget Model:Bot 级小模型基础设施
一个模型设置覆盖全部后台任务:
// 压缩模型 fallback
modelID := botSettings.CompactionModelID // 用户专门设的
if modelID == "" {
modelID = botSettings.HeartbeatModelID // 预算模型
}
if modelID == "" {
modelID = botSettings.ChatModelID // 主模型
}
心跳 Phase 2 直接使用主模型(告警决策需要高智能,且触发频率极低)。
2. 多模态回退管线
问题根因有两层:
第一层:routeAndMergeAttachments() 根据模型能力路由附件。文本模型把图片扔进 Fallback 桶,转成 tool_file_ref。extractNativeImageParts() 只能从 Native 桶提取——永远为空。
第二层:即使图片进入管线并被成功描述,如果管线失败(如 Vision Model API 异常),图片会原样送给主模型,导致 DeepSeek 报 unknown variant image_url。
修复:
// resolver.go — 直接从原始附件提取图片
if !runCfg.SupportsImageInput && runCfg.VisionModelID != "" {
fallbackImages := extractImagePartsForVisionFallback(ctx, r, req)
runCfg.InlineImages = append(runCfg.InlineImages, fallbackImages...)
}
// prepareRunConfig — 管线失败时丢弃图片而非原样发送
if err := runMessagePipeline(...); err != nil {
cfg.Query = origQuery
cfg.InlineImages = nil // 宁可丢图,不让模型崩
}
3. 消息管线架构:开闭原则
// 接口 — 只需两个方法
type MessageProcessor interface {
Name() string
Process(ctx context.Context, state *MessageState) error
}
// 状态 — 每个处理器修改自己关心的字段
type MessageState struct {
Query string
InlineImages []sdk.ImagePart
// 未来:InlineAudio, InlineVideo, InlineFiles
}
// 注册 — 一行代码加新处理器
func BuildMessagePipeline(cfg VisionConfig, deps PipelineDeps) []MessageProcessor {
if !cfg.SupportsVision && cfg.VisionModelID != "" {
processors = append(processors, NewImageDescriptionProcessor(...))
}
// 未来:
// if !cfg.SupportsAudio && cfg.AudioModelID != "" { ... }
return processors
}
全部依赖注入,每个组件独立可测。测试直接用 mock deps:
p := NewImageDescriptionProcessor(ImageProcessorDeps{
FetchModel: func(...) { return mockModel, nil },
ResolveCredentials: func(...) { return mockCreds, nil },
HTTPClient: http.DefaultClient,
})
关键设计决策
| 决策 | 理由 |
|---|---|
| Phase 2 用主模型而非预算模型 | 告警频率极低(< 10%),需要高推理质量,成本影响可忽略 |
| 压缩保留三层 fallback | 压缩质量关乎任务完成度,不能静默跳过 |
| 管线失败 → 丢弃图片 | image_url 错误会导致整个请求崩溃,丢图比丢消息好 |
| 管线顺序执行 | 处理器可能有状态依赖(如文件解析依赖图片描述结果) |
| 依赖注入而非 Resolver 方法 | 测试不需要完整 Resolver,mock 即可 |
未来扩展方向
- 音频处理器:语音消息 → Whisper 模型 → 文本注入
- 文件处理器:PDF/Docx → 解析 → 文本摘要
- 管线并行化:独立处理器可并发执行
- 模型能力自动检测:根据模型配置自动构建处理器列表
- 缓存层:相同图片的重复描述可跳过