鸿蒙HarmonyOS 多模态输入实战 —— 图片与文档输入智谱/DeepSeek
2026/7/17 22:48:07
网站开发
一、前言多模态的三个难题假设你让 Agent 接收用户拍的烘焙成品照片分析烘焙效果。你查了智谱 API 文档发现它支持image_url。你写了const message new AgentMessage(MessageRole.user, [ ContentPart.image(base64Data) // 希望发送图片 ])但你很快遇到三个难题怎么构造图片 ContentPartContentPart没有公开的image()工厂。直接 new 吗data 字段填什么需要加data:image/jpeg;base64,前缀吗DeepSeek 不支持图片怎么办如果用户选了 DeepSeek Provider 但发了图片你要在什么时候拦截——发请求前还是等 Provider 报错文档输入怎么处理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 }audio和video在 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.unknownrawKind保证了 Provider 升级新增内容类型时不会崩溃。三、图片输入Base64 与 data URL3.1 MediaContent 工厂// 图片工厂 MediaContent.imageFromBase64(base64Data, mimeType)工厂内部做了两件事stripDataUrlPrefix如果用户传的 base64 带data:image/jpeg;base64,前缀先去掉前缀只保留纯 base64 数据存在data字段。metadata 记录 byteLengthEstimate估算解码后字节大小用于大小校验。stripDataUrlPrefix的逻辑domain/Media.etsstatic 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拼回完整格式。buildDataUrl是stripDataUrlPrefix的逆操作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 字段记录 dataRedactedtrue dataChars byteLengthEstimate // URI 经 sanitizeUriForPersistence 去掉 data: scheme、userinfo、query、fragment // 无可发载荷时返回 text 占位toOmittedMediaPlaceholder为什么要去掉 URI 的 query因为 CDN/OSS 的签名 token 藏在 query 参数里?SignaturexxxExpiresyyy。如果原样持久化签名 token 就泄漏到文件了。3.4 sanitizeUriForPersistence 的处理sanitizeUriForPersistencedomain/Media.ets对 URI 做了四重清理// 1. 去掉 data: schemebase64 数据不应在 URI 里持久化 // 2. 去掉 userinfohttps://user:passhost 的 user:pass 部分 // 3. 去掉 query?SignaturexxxExpiresyyy 等签名参数 // 4. 去掉 fragment#anchor清理后只保留scheme://host/path——足够标识这是哪个资源但不泄漏任何凭据信息。3.5 toOmittedMediaPlaceholder无可发载荷时的占位如果图片/文档既没有 data 也没有合法 uri比如持久化后被脱敏了redactForPersistence会生成一个 text 占位static toOmittedMediaPlaceholder(part: ContentPart): ContentPart { // 生成类似 [image omitted: dataChars12345 mimeimage/png] 的 text return ContentPart.text( [${part.kind} omitted: dataChars${part.data?.length ?? 0} mime${part.mimeType ?? unknown}] ) }关键设计占位是 text block不是带 metadata 标记的 image block。阶段 8 的踩坑告诉我们——带 metadata 标记的占位可以被伪造伪造dataRedactedtrue绕过校验所以干脆用纯 text让 Wire 层无从信任。四、文档输入三种策略不同 Provider 对文档的支持差异巨大。ArkAgent 定义了三种DocumentWireStrategyexport enum DocumentWireStrategy { native native, // 原生 file_urlProvider 直接读取 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 DeepSeekhost_textDeepSeek 不支持原生文档输入只接受宿主提取的文本// 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;mimexxx]\n文本内容的格式发给 DeepSeek。4.3 策略对照Provider图片文档策略文档编码智谱 VLM (glm-4.6v 等)✅ image_urlnativefile_url (HTTPS)智谱文本 (glm-5.2 等)❌ rejectreject——DeepSeek❌ rejecthost_text带标签 text block4.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✅✅VLMimage_url file_urlglm-4.6v / glm-4.6v-flash / glm-4.6v-flashx✅✅VLM注意不能在同一请求混用 file/video/imageglm-4v-flash✅❌仅图片理解免费层无 file_url关键设计未知模型默认 text-onlytextOnlyCapability不做名称启发式猜测。注释写得很明确unknown zhipu model; text-only until listed in capability matrix。新模型必须显式添加到能力矩阵和 Profile 测试里不能靠模型名猜它支持什么。DeepSeek 模型矩阵更简单——全部不支持图片文档统一用 host_text模型图片文档deepseek-v4-flash❌host_textdeepseek-v4-pro❌host_textdeepseek-chat (deprecated)❌host_textdeepseek-reasoner (deprecated)❌host_text五、⚠️ 踩坑一DeepSeek 不支持图片5.1 症状用户选了 DeepSeek Provider发了一张图片。如果直接发给 ProviderDeepSeek 返回错误——API 文档不记录图片输入能力。5.2 修复pre-call 拒绝DeepSeek Profile 的encodeContentPart对图片直接返回 config errorif (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 DTO6.1 为什么不信任 public DTOContentPart是 public 类型任何人都能构造——包括恶意的或错误的。比如有人构造了一个ContentPart(kindimage)但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): Resultvoid, ArkAgentError { // 对每个 message 的每个 part 做工厂等价复验 // 统计 imageCount / documentCount超过 MediaLimits 报错 } }Guard 在 Wire 编码前对每个 ContentPart 做工厂等价复验——用MediaContent.revalidateImagePart/revalidateDocumentPart重新校验确保 data 合法、mimeType 在白名单内、大小不超限。6.3 MediaLimitsexport 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 看到dataRedactedtrue就以为数据已被合理省略实际数据从未存在过。7.2 修复删除 Wire 侧的占位转换不信任 metadata 标记State encode 直接写 text 占位不依赖标记decode 用专用的recoverPartsAfterStateLoad伪造标记零 HTTP 测试构造dataRedactedtrue但 data 为空的 ContentPart断言它被拒绝且 HTTP0八、HeaderPolicy敏感头脱敏8.1 RESERVED headersexport 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。✅ 持久化时剥离 base64redactForPersistence。✅ 文档根据 Provider 策略选择编码方式native file_url / host_text。✅ DeepSeek 文档用 host_text带标签 text block。✅ 不支持的 content kind 在 pre-callconfig 层拒绝。✅ Wire 编码前用ContentCapabilityGuard复验不信任 public DTO。✅ RESERVED headers 不可被 custom headers 覆盖。✅ 日志输出的 header 经redactHeadersForLog脱敏。❌ 不信任 metadata 布尔标记作为 provenance。❌ 不直接 new ContentPart用工厂。十一、常见错误对照表错误做法问题正确做法直接 new ContentPart(kindimage)无校验可能非法MediaContent.imageFromBase64 工厂data 字段含 data URL 前缀重复前缀stripDataUrlPrefix 去前缀给 DeepSeek 发图片Provider 不支持pre-call config error给智谱文本模型发图片文本模型无视觉能力用 VLM 模型给 DeepSeek 发 PDF base64DeepSeek 只接受 host_text宿主提取文本后发信任 metadata.dataRedacted 标记伪造标记绕过校验Guard 工厂等价复验持久化时保留完整 base64状态文件巨大redactForPersistence 剥离URI query 含签名 token 持久化签名泄漏sanitizeUriForPersistence 去 querycustom headers 覆盖 RESERVED破坏请求正确性返回 reserved_header 错误日志输出 Authorization密钥泄漏redactHeadersForLog 脱敏为 audio/video 创建空壳被误用pre-call 明确拒绝十二、验证清单智谱 VLM 接受图片image_url data URL智谱 VLM 接受文档file_url HTTPS URI智谱文本模型拒绝图片pre-call config errorDeepSeek 拒绝图片pre-call config errorDeepSeek 文档用 host_text带标签 text blockDeepSeek 文档拒绝 binary base64图片超过 7M base64 被拒单请求超过 8 图 / 4 文档被拒非白名单 MIME 被拒伪造 dataRedacted 被复验拦截HTTP0持久化后图片 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-daemonCompileArkTS passed BUILD SUCCESSFUL测试覆盖Phase8Multimodal.test.etsaudio/video 零 HTTP、forged dataRedacted、mixed_media_unsupported、host_text persist覆盖 303 个用例。十四、写在最后图片走工厂加校验base64 存纯不带缀。 持久化把数据剥URI 去 query 防 token 漏。 文档三策看 Providernative file_url 或 host_text。 DeepSeek 不吃图也不吃 PDF宿主提取文本才靠谱。 Guard 不信 public DTO工厂复验才放行。 metadata 标记可伪造provenance 不能靠它定。 RESERVED 头不可盖敏感头日志打星号。 audio video 1.0 不做pre-call 拒绝不留壳。