鸿蒙HarmonyOS 多模态输入实战 —— 图片与文档输入智谱/DeepSeek

一、前言:多模态的"三个难题"

假设你让 Agent 接收用户拍的烘焙成品照片,分析烘焙效果。你查了智谱 API 文档,发现它支持 image_url。你写了:

复制代码
const message = new AgentMessage(MessageRole.user, [
  ContentPart.image(base64Data)   // 希望发送图片
])

但你很快遇到三个难题:

  1. 怎么构造图片 ContentPart? ContentPart 没有公开的 image() 工厂。直接 new 吗?data 字段填什么?需要加 data:image/jpeg;base64, 前缀吗?

  2. DeepSeek 不支持图片怎么办? 如果用户选了 DeepSeek Provider 但发了图片,你要在什么时候拦截------发请求前?还是等 Provider 报错?

  3. 文档输入怎么处理? PDF 文件的 base64 直接发给 Provider 吗?智谱和 DeepSeek 的文档格式一样吗?

这些问题涉及从领域模型、Provider 差异、到安全校验的完整链路。ArkAgent 用 ContentPart + MediaContent + Provider Profile + ContentCapabilityGuard 解决这些问题。


二、ContentPart:多模态内容的基本单位

2.1 ContentKind 五种类型

复制代码
export enum ContentKind {
  text = 'text',
  image = 'image',
  audio = 'audio',
  video = 'video',
  document = 'document',
  unknown = 'unknown'   // 未知类型保留 rawKind
}

audiovideo 在 1.0 是保留类型------领域模型里有定义(序列化兼容),但 Provider 在网络调用前明确拒绝。ADR-0014 记录了这条决策:"Domain 中既有 audio/video 类型为序列化兼容保留,1.0 Provider 必须在网络调用前明确拒绝。"

2.2 ContentPart 字段

复制代码
export class ContentPart {
  readonly kind: ContentKind
  readonly rawKind?: string       // 未知类型保留原始字符串
  readonly text?: string
  readonly data?: string          // base64 编码数据
  readonly uri?: string           // HTTPS URI
  readonly mimeType?: string
  readonly metadata?: JsonObject
}

只有 ContentPart.text() 是公开静态工厂。图片和文档必须通过 MediaContent 工厂创建------因为它们需要额外校验(MIME 类型、大小限制、base64 合法性)。

2.3 rawKind:未知类型的 raw fallback

和 JSON 体系的"未知枚举保留 raw value"一致,ContentKind.unknown + rawKind 保证了 Provider 升级新增内容类型时不会崩溃。


三、图片输入:Base64 与 data URL

3.1 MediaContent 工厂

复制代码
// 图片工厂
MediaContent.imageFromBase64(base64Data, mimeType)

工厂内部做了两件事:

  1. stripDataUrlPrefix :如果用户传的 base64 带 data:image/jpeg;base64, 前缀,先去掉前缀,只保留纯 base64 数据存在 data 字段。

  2. metadata 记录 byteLengthEstimate:估算解码后字节大小,用于大小校验。

stripDataUrlPrefix 的逻辑(domain/Media.ets):

复制代码
static stripDataUrlPrefix(source: string): string {
  const lower = source.substring(0, 5).toLowerCase()
  if (lower === 'data:') {
    const commaIdx = source.indexOf(',')
    if (commaIdx >= 0) {
      return source.substring(commaIdx + 1)  // 去掉 "data:xxx;base64," 前缀
    }
  }
  return source  // 不含前缀,原样返回
}

这个函数的设计原则是"宽容输入,严格存储"------用户可能传带前缀的、也可能传不带的,工厂都接受,但 data 字段最终只存纯 base64。Wire 编码时再用 buildDataUrl 拼回完整格式。

buildDataUrlstripDataUrlPrefix 的逆操作:

复制代码
static buildDataUrl(base64Data: string, mimeType: string): string {
  return `data:${mimeType};base64,${base64Data}`
}

3.2 图片使用示例

复制代码
import { ContentPart, MediaContent } from '@arkagent/core'

// 方式一:从 base64 创建图片 Part
const imagePart = MediaContent.imageFromBase64(
  'iVBORw0KGgoAAAANSUhEUgAA...',  // 纯 base64(无前缀)
  'image/png'
)

// 方式二:从 HTTPS URI 创建
const uriPart = ContentPart.imageFromUri(
  'https://example.com/photo.jpg',
  'image/jpeg'
)

// 组装成消息
const message = new AgentMessage(MessageRole.user, [
  ContentPart.text('分析这张烘焙成品照片'),
  imagePart
])

注意:imageFromUri 只接受 HTTPS URI------和 UrlUtil.ensureHttps 的安全约束一致。

3.2 Provider 编码:image_url

智谱 VLM 模型接受 OpenAI 格式的 image_url

复制代码
// ZhipuProviderProfile.encodeContentPart
if (part.kind === ContentKind.image) {
  const caps = this.resolveModelCapability(modelId)
  if (!caps.supportsImageInput) {
    return Result.failure(ArkAgentError.config('unsupported_content',
      `Model ${modelId} does not support image input`))
  }
  let url: string
  if (part.uri !== undefined && part.uri.length > 0) {
    url = part.uri                          // HTTPS URI 直接用
  } else if (part.data !== undefined && part.data.length > 0) {
    url = MediaContent.buildDataUrl(part.data, part.mimeType ?? 'image/jpeg')
    // buildDataUrl 重新拼回 data URL 格式
  }
  return Result.success(new JsonObject()
    .set('type', JsonValue.string('image_url'))
    .set('image_url', JsonValue.object(new JsonObject().set('url', JsonValue.string(url)))))
}

关键data 字段存的是纯 base64 (无前缀),Wire 编码时才用 buildDataUrl 拼回完整 data URL。这保证了存储格式和传输格式的分离。

3.3 持久化脱敏

图片的 base64 数据很大,持久化到 AgentState 会产生巨大的状态文件。MediaContent.redactForPersistence 在 State encode 时剥离 base64:

复制代码
// 持久化时
// 剥离 data 字段,记录 dataRedacted=true + dataChars + byteLengthEstimate
// URI 经 sanitizeUriForPersistence 去掉 data: scheme、userinfo、query、fragment
// 无可发载荷时返回 text 占位(toOmittedMediaPlaceholder)

为什么要去掉 URI 的 query? 因为 CDN/OSS 的签名 token 藏在 query 参数里(?Signature=xxx&Expires=yyy)。如果原样持久化,签名 token 就泄漏到文件了。

3.4 sanitizeUriForPersistence 的处理

sanitizeUriForPersistencedomain/Media.ets)对 URI 做了四重清理:

复制代码
// 1. 去掉 data: scheme(base64 数据不应在 URI 里持久化)
// 2. 去掉 userinfo(https://user:pass@host 的 user:pass 部分)
// 3. 去掉 query(?Signature=xxx&Expires=yyy 等签名参数)
// 4. 去掉 fragment(#anchor)

清理后只保留 scheme://host/path------足够标识"这是哪个资源",但不泄漏任何凭据信息。

3.5 toOmittedMediaPlaceholder:无可发载荷时的占位

如果图片/文档既没有 data 也没有合法 uri(比如持久化后被脱敏了),redactForPersistence 会生成一个 text 占位:

复制代码
static toOmittedMediaPlaceholder(part: ContentPart): ContentPart {
  // 生成类似 "[image omitted: dataChars=12345 mime=image/png]" 的 text
  return ContentPart.text(
    `[${part.kind} omitted: dataChars=${part.data?.length ?? 0} mime=${part.mimeType ?? 'unknown'}]`
  )
}

关键设计 :占位是 text block,不是带 metadata 标记的 image block。阶段 8 的踩坑告诉我们------带 metadata 标记的占位可以被伪造(伪造 dataRedacted=true 绕过校验),所以干脆用纯 text,让 Wire 层无从"信任"。


四、文档输入:三种策略

不同 Provider 对文档的支持差异巨大。ArkAgent 定义了三种 DocumentWireStrategy

复制代码
export enum DocumentWireStrategy {
  native = 'native',       // 原生 file_url(Provider 直接读取 HTTPS 文档)
  host_text = 'host_text', // 宿主提取文本(App 自己解析文档,发送纯文本)
  reject = 'reject'        // 拒绝(不支持文档)
}

4.1 智谱:native file_url

智谱 VLM 模型支持 file_url

复制代码
// ZhipuProviderProfile --- document + DocumentWireStrategy.native
if (part.uri !== undefined && part.uri.length > 0) {
  return Result.success(new JsonObject()
    .set('type', JsonValue.string('file_url'))
    .set('file_url', JsonValue.object(new JsonObject()
      .set('url', JsonValue.string(part.uri)))))
}

需要 HTTPS URI------智谱服务器直接读取这个 URL 的文档内容。

4.2 DeepSeek:host_text

DeepSeek 不支持原生文档输入,只接受宿主提取的文本:

复制代码
// DeepSeekProviderProfile --- document + DocumentWireStrategy.host_text
// 拒绝 binary base64
if (part.data !== undefined && part.data.length > 0) {
  return Result.failure(ArkAgentError.config('unsupported_content',
    'DeepSeek requires host-extracted text documents; binary base64 rejected'))
}
// 只接受纯文本,编码为带标签的 text block
const labeled = `[document:${name};mime=${part.mimeType ?? 'text/plain'}]\n${part.text}`
return Result.success(new JsonObject()
  .set('type', JsonValue.string('text'))
  .set('text', JsonValue.string(labeled)))

App 自己负责把 PDF/Word 解析成文本,然后以 [document:name;mime=xxx]\n文本内容 的格式发给 DeepSeek。

4.3 策略对照

Provider 图片 文档策略 文档编码
智谱 VLM (glm-4.6v 等) ✅ image_url native file_url (HTTPS)
智谱文本 (glm-5.2 等) ❌ reject reject ------
DeepSeek ❌ reject host_text 带标签 text block

4.4 智谱模型能力矩阵

智谱的模型按视觉能力分两类(docsCheckedAt: 2026-07-13):

模型 图片 文档 说明
glm-5.2 / glm-5.1 / glm-5 纯文本模型
glm-4.7 / glm-4.6 / glm-4.5-air 纯文本模型
glm-5v-turbo VLM:image_url + file_url
glm-4.6v / glm-4.6v-flash / glm-4.6v-flashx VLM(注意:不能在同一请求混用 file/video/image)
glm-4v-flash 仅图片理解(免费层无 file_url)

关键设计 :未知模型默认 text-only(textOnlyCapability),不做名称启发式猜测。注释写得很明确:"unknown zhipu model; text-only until listed in capability matrix"。新模型必须显式添加到能力矩阵和 Profile 测试里,不能靠模型名"猜"它支持什么。

DeepSeek 模型矩阵更简单------全部不支持图片,文档统一用 host_text:

模型 图片 文档
deepseek-v4-flash host_text
deepseek-v4-pro host_text
deepseek-chat (deprecated) host_text
deepseek-reasoner (deprecated) host_text

五、⚠️ 踩坑一:DeepSeek 不支持图片

5.1 症状

用户选了 DeepSeek Provider,发了一张图片。如果直接发给 Provider,DeepSeek 返回错误------API 文档不记录图片输入能力。

5.2 修复:pre-call 拒绝

DeepSeek Profile 的 encodeContentPart 对图片直接返回 config error:

复制代码
if (part.kind === ContentKind.image) {
  return Result.failure(ArkAgentError.config('unsupported_content',
    `DeepSeek model ${modelId} does not support image input per official API docs (2026-07-13)`))
}

错误在调用前(config 层)就拒绝,不浪费网络请求。错误信息注明了 API 文档验证日期(2026-07-13),方便后续复验。

5.3 教训

Provider 的能力差异必须在 Profile 里显式声明,不能"发了再说"。pre-call 校验比 Provider 报错体验好得多------用户能立即知道"这个 Provider 不支持图片",而不是等了几秒后看到 Provider 的 400 错误。


六、ContentCapabilityGuard:不信任 public DTO

6.1 为什么不信任 public DTO

ContentPart 是 public 类型,任何人都能构造------包括恶意的或错误的。比如有人构造了一个 ContentPart(kind=image)data 是空的,或者 mimeType 是伪造的。如果 Wire 层直接信任这些字段,可能发出非法请求。

6.2 ContentCapabilityGuard

复制代码
/**
 * Shared pre-call content validation used by OpenAIWireCodec.
 * Re-validates all media DTOs (do not trust public ContentPart constructors).
 */
export class ContentCapabilityGuard {
  static validateRequest(request: ModelRequest, profile: ProviderProfile,
    mediaLimits?: MediaLimits): Result<void, ArkAgentError> {
    // 对每个 message 的每个 part 做工厂等价复验
    // 统计 imageCount / documentCount,超过 MediaLimits 报错
  }
}

Guard 在 Wire 编码前对每个 ContentPart 做"工厂等价复验"------用 MediaContent.revalidateImagePart / revalidateDocumentPart 重新校验,确保 data 合法、mimeType 在白名单内、大小不超限。

6.3 MediaLimits

复制代码
export class MediaLimits {
  // base64 上限 7_000_000 字符(≈5 MiB 解码后)
  // 文档纯文本上限 200_000 字符
  // 单请求最多 8 张图片
  // 单请求最多 4 个文档
}

6.4 MIME 白名单

复制代码
image: png / jpeg / jpg / webp / gif
binary doc: application/pdf
text doc: text/plain / markdown / csv / json / html

不在白名单的 MIME 类型直接拒绝。


七、⚠️ 踩坑二:伪造 metadata 绕过校验

7.1 症状

阶段 8 报告记录的踩坑:公共 metadata 布尔值被当作 provenance------有人伪造了 dataRedacted: true 标记,绕过了 empty_media 校验(Guard 看到 dataRedacted=true 就以为"数据已被合理省略",实际数据从未存在过)。

7.2 修复

  • 删除 Wire 侧的占位转换(不信任 metadata 标记)

  • State encode 直接写 text 占位(不依赖标记)

  • decode 用专用的 recoverPartsAfterStateLoad

  • 伪造标记零 HTTP 测试 :构造 dataRedacted=true 但 data 为空的 ContentPart,断言它被拒绝且 HTTP=0


八、HeaderPolicy:敏感头脱敏

8.1 RESERVED headers

复制代码
export class HeaderPolicy {
  static readonly RESERVED: string[] = [
    'authorization', 'content-type', 'accept', 'host',
    'content-length', 'transfer-encoding', 'connection', 'proxy-authorization'
  ]
}

RESERVED headers 不可被 custom headers 覆盖------如果业务试图设置这些 header,返回 reserved_header 错误(不静默丢弃)。

8.2 SENSITIVE 脱敏

复制代码
static readonly SENSITIVE_NAME_HINTS: string[] = [
  'authorization', 'api-key', 'apikey', 'x-api-key',
  'token', 'secret', 'password', 'cookie', 'set-cookie'
]

static redactHeadersForLog(headers: HttpHeaders): JsonObject {
  // 敏感头的值输出为 ***
}

所有日志输出的 header 都经过 redactHeadersForLog 脱敏------Authorization、Cookie、API Key 等一律变成 ***


九、为什么 1.0 不做音视频

ADR-0014 的决策:

"用户于 2026-07-13 明确要求暂不支持 Responses、Gemini、Claude、Bedrock、音频和视频。"
"不为范围外能力创建空壳实现。Domain 中既有 audio/video 类型为序列化兼容保留,1.0 Provider 必须在网络调用前明确拒绝。"

这个决策的原则是不为范围外能力留空壳 ------空壳 API 容易被误用,不如明确拒绝。audio/video 在 Domain 里有定义(序列化兼容),但 Provider 层在网络调用前直接返回 unsupported_content


十、最佳实践清单

  • ✅ 图片通过 MediaContent.imageFromBase64 工厂创建(有校验)。

  • data 字段存纯 base64(无前缀),Wire 编码时拼 data URL。

  • ✅ 持久化时剥离 base64(redactForPersistence)。

  • ✅ 文档根据 Provider 策略选择编码方式(native file_url / host_text)。

  • ✅ DeepSeek 文档用 host_text(带标签 text block)。

  • ✅ 不支持的 content kind 在 pre-call(config 层)拒绝。

  • ✅ Wire 编码前用 ContentCapabilityGuard 复验(不信任 public DTO)。

  • ✅ RESERVED headers 不可被 custom headers 覆盖。

  • ✅ 日志输出的 header 经 redactHeadersForLog 脱敏。

  • ❌ 不信任 metadata 布尔标记作为 provenance。

  • ❌ 不直接 new ContentPart(用工厂)。


十一、常见错误对照表

错误做法 问题 正确做法
直接 new ContentPart(kind=image) 无校验,可能非法 MediaContent.imageFromBase64 工厂
data 字段含 data URL 前缀 重复前缀 stripDataUrlPrefix 去前缀
给 DeepSeek 发图片 Provider 不支持 pre-call config error
给智谱文本模型发图片 文本模型无视觉能力 用 VLM 模型
给 DeepSeek 发 PDF base64 DeepSeek 只接受 host_text 宿主提取文本后发
信任 metadata.dataRedacted 标记 伪造标记绕过校验 Guard 工厂等价复验
持久化时保留完整 base64 状态文件巨大 redactForPersistence 剥离
URI query 含签名 token 持久化 签名泄漏 sanitizeUriForPersistence 去 query
custom headers 覆盖 RESERVED 破坏请求正确性 返回 reserved_header 错误
日志输出 Authorization 密钥泄漏 redactHeadersForLog 脱敏
为 audio/video 创建空壳 被误用 pre-call 明确拒绝

十二、验证清单

  • 智谱 VLM 接受图片(image_url + data URL)

  • 智谱 VLM 接受文档(file_url + HTTPS URI)

  • 智谱文本模型拒绝图片(pre-call config error)

  • DeepSeek 拒绝图片(pre-call config error)

  • DeepSeek 文档用 host_text(带标签 text block)

  • DeepSeek 文档拒绝 binary base64

  • 图片超过 7M base64 被拒

  • 单请求超过 8 图 / 4 文档被拒

  • 非白名单 MIME 被拒

  • 伪造 dataRedacted 被复验拦截(HTTP=0)

  • 持久化后图片 base64 被剥离

  • RESERVED headers 不可覆盖

  • 日志 header 脱敏

  • audio/video pre-call 拒绝


十三、构建验证

复制代码
NODE_HOME=/Applications/DevEco-Studio.app/Contents/tools/node \
  DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk \
  /Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw assembleHar --no-daemon

CompileArkTS passed
BUILD SUCCESSFUL

测试覆盖:Phase8Multimodal.test.ets(audio/video 零 HTTP、forged dataRedacted、mixed_media_unsupported、host_text persist),覆盖 303 个用例。


十四、写在最后

图片走工厂加校验,base64 存纯不带缀。 持久化把数据剥,URI 去 query 防 token 漏。 文档三策看 Provider,native file_url 或 host_text。 DeepSeek 不吃图也不吃 PDF,宿主提取文本才靠谱。 Guard 不信 public DTO,工厂复验才放行。 metadata 标记可伪造,provenance 不能靠它定。 RESERVED 头不可盖,敏感头日志打星号。 audio video 1.0 不做,pre-call 拒绝不留壳。

相关推荐
廋到被风吹走1 小时前
【AI】GPT-5.6三模型齐发、世界模型崛起、Anthropic冲刺IPO
大数据·人工智能·gpt
hongyangcao1 小时前
GPT-5.6 Sol失控删除用户文件:OpenAI最新AI模型越权事件深度解读
人工智能·gpt
wasp5201 小时前
Vibe-Trading 深度解析(一):用 LLM 驱动的完整股票研究智能体架构总览
人工智能·架构·交易·ai coding·vibe trading
@灯神1 小时前
SpringAI系列|第1篇:SpringAI概述与快速上手
java·人工智能·spring·ai·ai编程
美狐美颜sdk1 小时前
自研还是第三方?直播APP开发中视频美颜SDK如何选择?
人工智能
Ljwuhe1 小时前
C++——模板进阶
开发语言·c++
aqi001 小时前
15天学会AI应用开发(十四)搭建LangChain的开发环境
人工智能·python·大模型·ai编程·ai应用
科技发布1 小时前
传统流量损耗严重,拓氪科技 AIGEO 怎样缩短转化链路?
人工智能·科技