← 返回上一页

第8章: 多模态快速上手:图片/文本输入与结构化输出

用“结论 + 示例 + 工程化代码”搞懂:图片/文本输入、结构化输出与工程落地

🎯 本章目标

多模态(Multimodal)并不神秘:本质上就是把图片/文本/(可选:音频)放进同一个请求里,让模型基于这些输入生成结果。 本章你要带走的是“能用的结论”,而不是概念名词。

✅ 三个结论(先记住)

结论1:多模态 = 多输入,不是多模型。你只是在一次请求里同时提供 text + image。
结论2:工程上要优先结构化输出。让模型输出 JSON,比输出一大段话更容易落地到产品里。
结论3:LangChain 的价值在“编排”。多模态调用本身可以直接用 DashScope,但 LangChain 能把“识图 → 结构化 → 生成文案/代码/报告”串成可维护流程。

🧩 多模态请求长什么样?(核心心智模型)

把一次多模态调用理解成: Prompt(文字指令) + Context(图片) → Model → Output(文字/JSON)。 图片不是“让模型看一眼”,而是模型推理的上下文。

输入的两种常见方式

🏗️ 生产链路长什么样?(从研发到上线)

生产链路(从研发到上线) 上层:在线运行链路 | 中层:研发发布链路 | 下层:业务编排与可观测 读图指南: 每个框:名称 + 关键动作(小字) 数据接入 上传 / URL / 文件 产出:统一图片输入 安全预检与治理 大小/类型/脱敏/缓存/配额 拒绝/标准化/限流 多模态模型 qwen-vl-* 原始响应 + 用量/耗时 结构化输出合同 JSON Schema 校验 产出:稳定 JSON 研发发布链路(从代码到上线) 目标:可回归、可灰度、可回滚 评审 Prompt/Schema 接口化:字段/边界 测试 样本回放/回归 对比:准确/成本 CI/CD 构建/发布 版本号:模型/Prompt 灰度发布 小流量 对照组:旧版本 监控告警 成功率/成本 P95/P99 + 失败分布 回滚 旧版本 一键切回 + 回放 业务编排层(工具 / 报告 / 人审) 路由/重试/降级/降级模型 → 审计日志 + trace_id + 指标(耗时/成功率/成本) 核心指标:schema 校验失败率|拒绝原因分布|缓存命中率|单请求成本|尾延迟 P95/P99 版本管理:模型版本/Prompt 版本/Schema 版本/策略版本(可追溯,可回放)
关键:上线要用结构化合同 + 治理 + 可观测 + 发布体系把多模态从“能跑”变成“可维护、可回滚、可持续迭代”。

🏗️ 建议的生产化分层架构(研发 → 上线 → 运营)

多模态生产化分层架构 研发层 → 上线层 → 运营层,每层职责清晰、接口明确 🔧 研发层(Dev)— 目标:可复现、可验证 Prompt工程 模板/变量/版本 Schema定义 JSON合同/校验 样本集管理 好/坏/边界样本 评测脚本 回归/基线对比 指标基线 准确/成本/延迟 🚀 上线层(Prod)— 目标:稳定、可控、可观测 数据接入 上传/URL/OSS 预检治理 安全/限流/缓存 模型网关 超时/重试/主备 结构化输出 Schema/校验 业务编排 工具/工作流 审计日志 trace_id/可观测 评审 → 测试 CI/CD构建 灰度发布 监控告警 一键回滚 📊 运营层(Ops)— 目标:持续优化、成本控制 核心指标看板 成功率/成本/延迟/P95 错误归因分析 拒绝原因/失败分布 成本优化策略 缓存/压缩/模型选型 Prompt迭代 A/B测试/版本对比 反馈闭环 BadCase回流
🔧 第一层:研发层(Dev) 目标:可复现、可验证
Prompt工程:模板化设计、变量抽象、版本管理
Schema定义:JSON合同、字段类型、校验规则
样本集管理:好样本/坏样本/边界样本分类存储
评测脚本:回归测试、基线对比、指标自动化
💡 关键产出:可复现的评测脚本 + 样本集清单 + 基线指标(准确率/拒绝率/成本/延迟)
🚀 第二层:上线层(Prod) 目标:稳定、可控、可观测
数据接入:上传/URL/OSS、格式校验、大小限制
预检治理:安全脱敏、限流配额、缓存命中
模型网关:超时控制、重试策略、主备切换
结构化输出:Schema校验、错误修复、兼容版本
业务编排:工具调用、工作流、人审介入
审计日志:trace_id串联、全链路追踪
📋 CI/CD流程:评审 → 测试 → 构建 → 灰度 → 监控 → 回滚(每个环节可回溯)
📊 第三层:运营层(Ops) 目标:持续优化、成本控制
核心指标看板:成功率、成本趋势、延迟分布、P95/P99
错误归因分析:拒绝原因分布、失败模式分类、告警分级
成本优化策略:缓存复用、图片压缩、模型降级、Token控制
Prompt迭代:A/B测试、版本对比、BadCase回流优化

⚙️ 标准工程示例:通义多模态识图(DashScope)

本章先用 DashScope 的多模态接口给出一套标准工程示例。 同时也会给出 LangChain 在多模态里的典型用法与编排方式(知识点)。

通义多模态识图:请求/响应在工程里怎么走 输入(图片 + 指令) image_url / file:// Prompt 模板 + 变量 输入治理 / 预检 大小/格式/脱敏/限流 必要时 resize + 缓存 模型调用 qwen-vl-plus 超时/重试/主备 结构化输出 JSON Schema 校验/修复/落库 可观测与审计(贯穿全链路) trace_id / request_id / 供应商错误码 / 耗时分段(precheck/model_call/post_parse) 核心指标:成功率|JSON 解析成功率|Schema 通过率|P95/P99|单请求成本|拒绝原因分布 关键策略:缓存(相同图片复用结果)|降级(切模型/切路径)|版本管理(Prompt/Schema/模型)

这段代码解决什么问题?

目标:把“识图结果”变成稳定的 JSON,让后端/前端可以直接消费。
关键点:不把结果当文本展示,而是把返回当接口响应处理。
工程习惯:参数校验 → 模型调用 → JSON 解析 → 错误分类 → trace 贯穿。

你需要重点关注的 4 个接口面

输入:image_url 推荐 file://,避免外网不可达导致失败。
提示词:明确输出字段与类型,避免“描述一大段话”。
输出:强制 JSON;失败要可修复(如抽取 JSON、schema 校验)。
错误:区分 input/provider/parse/policy,便于定位与告警。

1) 安装依赖

text
# 目的:安装 DashScope Python SDK(通义千问官方/半官方调用入口)
#
# 提示:
# - 生产环境建议固定版本号(避免 API 行为变更导致线上不稳定)
# - 课堂演示可先不锁版本,跑通后再补 requirements.txt
pip install dashscope

2) 设置 API Key

text
# 目的:把 API Key 放到环境变量中,避免硬编码到源码(安全/可移植)
#
# 常见坑:
# - macOS/zsh:配置后要重新打开终端或 source 配置文件才能生效
# - IDE 运行:需要在 Run Configuration 里也配置环境变量
export DASHSCOPE_API_KEY="你的Key"

3) 识图 + 输出结构化 JSON(关键)

python
import os
import json
import re
import dashscope
from dashscope import MultiModalConversation


# =========================
# 0) 准备:API Key 与依赖
# =========================
# 设置 DashScope API Key(从环境变量读取)
# 说明:
# - 不要把 key 写进代码/仓库
# - 线上一般由容器/CI 注入环境变量
dashscope.api_key = os.getenv("DASHSCOPE_API_KEY")
if not dashscope.api_key:
    raise ValueError("请先设置环境变量 DASHSCOPE_API_KEY")


def parse_image_to_json(image_url: str) -> dict:
    """
    多模态识图并返回结构化 JSON
    Args:
        image_url: 图片地址(支持 file:// 本地路径,最稳定)
    Returns:
        dict: 包含 summary, entities, need_more_info 的结构化数据
    """
    # =========================
    # 1) 输入校验(上线必做)
    # =========================
    # 参数校验:确保传入有效图片地址
    if not image_url:
        raise ValueError("请传入图片地址(推荐使用 file:// 本地路径,最稳定)")

    # 防止使用示例占位符地址
    if "example.com" in image_url:
        raise ValueError("请传入真实图片(不要用 example.com 这种占位地址)")

    # =========================
    # 2) Prompt:把“输出合同”说清楚
    # =========================
    # 构造提示词:明确要求输出 JSON 格式,定义返回结构
    # 教学重点:
    # - 多模态上线的关键不是“让它说得好”,而是“让它输出可解析的结构化结果”
    # - prompt 必须写清楚:字段名 + 字段类型 + 输出必须是 JSON(不要解释文字)
    # - 结构越明确,解析与下游消费成本越低(也是 Guardrails/Schema 校验的前提)
    prompt = (
        "你是一个严谨的识图助手。请根据图片内容提取关键信息,并严格输出 JSON。\n"
        "输出格式:{\"summary\": string, \"entities\": [string], \"need_more_info\": [string]}"
    )

    # =========================
    # 3) 组装多模态 messages(文本 + 图片)
    # =========================
    # 构造多模态消息:包含文字指令 + 图片
    # 注意:这里的 image_url 建议优先使用 file:// 本地路径,外网 URL 可能因网络/权限失败
    messages = [
        {
            "role": "user",
            "content": [
                {"text": prompt},        # 文字指令
                {"image": image_url},    # 图片输入
            ],
        }
    ]

    # =========================
    # 4) 模型调用(外部依赖,失败概率最高)
    # =========================
    # 调用通义千问多模态模型
    # 说明:
    # - model 选择会影响价格/速度/效果
    # - 生产环境要加:超时、重试、限流、熔断、trace_id
    resp = MultiModalConversation.call(
        model="qwen-vl-plus",  # 使用支持多模态的模型
        messages=messages,
    )

    # =========================
    # 5) 错误分类(让失败“可定位、可告警”)
    # =========================
    # 错误处理:检查 API 调用是否成功
    if resp is None or getattr(resp, "output", None) is None:
        raise RuntimeError(
            f"DashScope 调用失败:status_code={getattr(resp, 'status_code', None)} "
            f"code={getattr(resp, 'code', None)} message={getattr(resp, 'message', None)} "
            f"request_id={getattr(resp, 'request_id', None)}"
        )

    # 检查返回结果是否包含有效选择
    if not getattr(resp.output, "choices", None):
        raise RuntimeError(
            f"DashScope 返回为空:status_code={getattr(resp, 'status_code', None)} "
            f"code={getattr(resp, 'code', None)} message={getattr(resp, 'message', None)} "
            f"request_id={getattr(resp, 'request_id', None)}"
        )

    # =========================
    # 6) 输出解析:把文本当成“接口响应”来处理
    # =========================
    # 提取模型返回的文本内容
    text = resp.output.choices[0].message["content"][0]["text"]

    # 清理文本:去除首尾空白
    text = (text or "").strip()
    if not text:
        raise RuntimeError("模型返回空内容(可能图片未正确读取/不支持该图片格式/图片尺寸过大)")

    # 处理可能的代码块标记(```json ... ```)
    # 常见坑:模型喜欢包 Markdown 代码块,不去掉就会 json.loads 失败
    if text.startswith("```"):
        text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE)  # 移除开头的 ```
        text = re.sub(r"\s*```$", "", text)  # 移除结尾的 ```

    # 尝试解析 JSON
    # 说明:
    # - 第一优先:直接 json.loads
    # - 第二优先:正则抽取第一个 {...}(容错)
    # - 生产可再加:schema 校验(字段/类型),失败走“修复回路”而不是重跑识图
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        # 容错处理:尝试从文本中提取 JSON 部分
        m = re.search(r"\{.*\}", text, flags=re.S)  # 使用正则提取 JSON 对象
        if m:
            try:
                return json.loads(m.group(0))
            except json.JSONDecodeError:
                pass
        raise RuntimeError(f"模型未返回合法 JSON:{text}")


if __name__ == "__main__":
    # 使用示例:替换为你本地图像的路径(支持相对路径)
    # 教学建议:
    # - 先用本地 file:// 跑通,再考虑 URL 图片(减少网络变量)
    local_path = "eagle.png"  # 替换为你的图片路径
    abs_path = os.path.abspath(local_path)
    
    # 构造 file:// 协议的本地文件路径
    image_path = f"file://{abs_path}"
    
    # 调用识图函数并打印结果
    result = parse_image_to_json(image_path)
    print(result)

✅ 你刚刚做对的关键点

  • 把输出合同写清楚:要求“严格 JSON”,减少后处理成本。
  • 把结果交给程序解析:json.loads 成功 = 可用;失败 = 提示词要改/要做容错。
  • 工程兜底:真实项目要加 try/except + 重试 + 超时 + 限流。

✅ 结构化输出再升级:Schema 校验 + 自动修复回路

生产里建议把"模型输出 JSON"当成合同:字段缺失/类型错误都要当作失败处理。更重要的是:失败时优先做"修复 JSON",而不是重新识图。

Schema 校验 + 自动修复回路:本章讲什么? 模型输出 原始文本 / JSON JSON 提取 去包裹 / 抽 {...} Schema 校验 字段完整性 / 类型 通过 业务消费 稳定 JSON ✓ 失败 自动修复 补字段 / 转类型 重新校验 仍失败 降级 / 告警 兜底返回 / 报错 核心目标:把"模型不可靠"变成"接口可靠" — 失败优先修复而非重跑,控制成本同时保证 99%+ 可用性

为什么需要这套机制?

模型不可靠:即使 prompt 再严谨,模型仍可能返回少字段、类型错、带解释文字的不合规 JSON。
业务要可靠:下游系统不能因"字段缺失"就报错,需要 99%+ 的接口可用性。
成本要可控:重新识图代价高(token + 时延),优先用"修复"代替"重跑"。

这套回路做了什么?

把输出当合同:定义 REQUIRED_FIELDS,字段缺失/类型错 = 校验失败,必须处理。
失败优先修复:补字段默认值、转类型、去 Markdown 包裹,而非重新调用识图 API。
修复后重校验:确保修复结果仍符合合同,否则降级兜底或上报告警。

🎯 本节重点

多模态不是魔法,而是工程。模型返回格式不可控是常态,业务系统却需要稳定接口。
本节代码完整演示:① 定义输出合同(Schema)→ ② 提取 JSON → ③ 校验 → ④ 自动修复 → ⑤ 重校验 → ⑥ 降级兜底,六步把模型的不确定性收敛成接口的确定性。
python
# 复制本段到 schema_repair_example.py 可直接运行验证
#
# 你将学到:
# - 为什么“合同校验”是多模态上线的关键(而不是让模型多说两句)
# - 为什么失败时优先“修 JSON”而不是“重跑识图”(更省钱、更快、更稳定)
# - 一个可落地的工程闭环:parse -> validate(合同校验)失败 -> repair(仅修 JSON,不重跑识图) -> re-validate
#
# 建议你带着问题读:
# 1) 如果模型返回了带解释文字的 JSON,怎么提取?
# 2) 如果字段缺失/类型错,怎么让系统“仍能对外提供稳定接口”?
# 3) repair 的提示词怎么写才能让模型只做修复、不重新发挥?

import json
import re
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple


# -------------------------
# 1) 定义你要的“输出合同”(Schema)
# -------------------------
REQUIRED_FIELDS = {"summary", "entities", "need_more_info"}


def extract_json_object(text: str) -> str:
    """从模型输出中提取 JSON 对象字符串。

    现实情况:模型可能返回 ```json ...``` 包裹、或在 JSON 前后夹杂解释文字。
    这里做工程可用的容错:优先提取第一个 {...}。
    """
    text = (text or "").strip()

    # 去掉 ```json``` 包裹
    # 常见坑:模型会输出 Markdown 代码块,直接 json.loads 会报错
    if text.startswith("```"):
        text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE)
        text = re.sub(r"\s*```$", "", text)

    # 抽取 {...}
    # 说明:这里只取第一个 JSON 对象,避免“解释文字 + JSON + 解释文字”导致解析失败
    m = re.search(r"\{.*\}", text, flags=re.S)
    return m.group(0) if m else text


def validate_contract(obj: Dict[str, Any]) -> Tuple[bool, str]:
    """校验输出是否满足合同。

    返回:
    - ok: 是否通过
    - reason: 失败原因(用于日志/告警/修复提示词)
    """
    # 1) 必填字段
    missing = [k for k in REQUIRED_FIELDS if k not in obj]
    if missing:
        return False, f"missing={missing}"

    # 2) 字段类型/内容约束:summary 不能为空
    if not isinstance(obj.get("summary"), str) or not obj.get("summary"):
        return False, "summary must be non-empty string"

    # 3) entities 必须是 list[str]
    if not isinstance(obj.get("entities"), list) or not all(isinstance(x, str) for x in obj["entities"]):
        return False, "entities must be list[str]"

    # 4) need_more_info 必须是 list[str]
    if not isinstance(obj.get("need_more_info"), list) or not all(
        isinstance(x, str) for x in obj["need_more_info"]
    ):
        return False, "need_more_info must be list[str]"

    return True, "ok"


def parse_and_validate(text: str) -> Dict[str, Any]:
    """解析 + 合同校验。

    - 解析失败:抛出 JSONDecodeError
    - 合同不合规:抛出 ValueError
    """
    # 先做“提取”再解析,避免 markdown/解释文字污染
    obj = json.loads(extract_json_object(text))
    ok, reason = validate_contract(obj)
    if not ok:
        raise ValueError(f"contract_invalid: {reason}")
    return obj


# -------------------------
# 2) “自动修复回路”
# -------------------------

from langchain_community.chat_models import ChatTongyi
from langchain_core.messages import HumanMessage


class VisionLLM:
    """真实的多模态 LLM 调用:使用 DashScope 通义千问 Vision 模型。"""

    def __init__(self, model_name: str = "qwen-vl-max"):
        """初始化 Vision LLM。

        需要提前配置环境变量:export DASHSCOPE_API_KEY="your-api-key"
        """
        self.llm = ChatTongyi(model_name=model_name, temperature=0)

    def call_vision(self, image_path: str, prompt: str) -> str:
        """调用多模态模型识别图片并返回文本结果。

        Args:
            image_path: 图片路径(本地文件路径,格式如 file:///path/to/img.png)
            prompt: 提示词,要求模型输出 JSON 格式

        Returns:
            模型返回的文本字符串
        """
        # 构造多模态消息:文本 + 图片
        message = HumanMessage(
            content=[
                {"text": prompt},
                {"image": image_path},
            ]
        )
        resp = self.llm.invoke([message])
        # 适配 Tongyi Vision 返回格式
        if isinstance(resp.content, list) and len(resp.content) > 0:
            return resp.content[0].get("text", "")
        return resp.content if isinstance(resp.content, str) else str(resp.content)

    def invoke(self, prompt: str) -> dict:
        """用于 JSON 修复的纯文本调用(不携带图片)。

        Args:
            prompt: 修复提示词

        Returns:
            dict 包含 content 字段,兼容原有接口
        """
        resp = self.llm.invoke(prompt)
        text = resp.content if isinstance(resp.content, str) else resp.content[0].get("text", "")
        return {"content": text}


def repair_contract_with_llm(bad_text: str, llm) -> str:
    """让模型仅修复 JSON,使其符合合同。

    关键点:
    - 不要在这里重新做识图/推理;只做"格式/字段/类型修复"。
    - 这通常更省钱、更快、更稳定。
    """
    prompt = (
        "你是 JSON 修复器。请把下面内容修复为严格 JSON,对象必须包含 summary/entities/need_more_info 三个字段。\n"
        "要求:\n"
        "1) 只输出 JSON,不要解释;\n"
        "2) entities/need_more_info 必须是字符串数组;\n"
        "3) summary 必须是非空字符串。\n\n"
        f"待修复内容:{bad_text}"
    )
    resp = llm.invoke(prompt)
    return resp.get("content", "") if isinstance(resp, dict) else str(resp)


def parse_validate_with_repair(text: str, llm, image_path: str = None) -> dict:
    """完整闭环:先 parse+validate,失败则走一次 repair,再 parse+validate。

    Args:
        text: 待解析的文本(如果是首次调用,传 None 或空字符串)
        llm: VisionLLM 实例
        image_path: 图片路径(首次调用时需要)
    """
    # =========================
    # A) 首次调用:需要识图
    # =========================
    if not text and image_path:
        prompt = (
            "你是识图助手。请分析这张图片,并严格输出 JSON 格式:\n"
            '{"summary": "图片描述", "entities": ["实体1", "实体2"], "need_more_info": []}\n'
            "要求:summary 是图片的整体描述;entities 是图中识别到的关键对象列表;need_more_info 是需要用户补充的信息。"
        )
        # 第一次模型调用:成本高、延迟高,所以后续优先 repair 而非重跑
        text = llm.call_vision(image_path, prompt)
        print(f"🖼️  原始识图输出:\n{text}\n")

    # =========================
    # B) 第一次:parse + validate
    # =========================
    try:
        return parse_and_validate(text)
    except Exception as e:
        print(f"⚠️  首次校验失败: {e}")
        print("🔧 启动自动修复回路...")

        # 生产中建议这里打日志:trace_id + error_type + reason
        # - error_type: parse_error / contract_invalid / provider_error
        # - reason: 具体缺字段/类型错/JSON 不合法
        repaired = repair_contract_with_llm(text, llm)
        print(f"✅ 修复后内容:\n{repaired}\n")

        # =========================
        # C) 修复后:re-parse + re-validate
        # =========================
        return parse_and_validate(repaired)


# -------------------------
# 3) 可运行验证:使用真实图片调用大模型
# -------------------------

if __name__ == "__main__":
    import os

    # 检查 API Key
    # 常见坑:IDE 里运行脚本时没有继承终端环境变量
    if not os.environ.get("DASHSCOPE_API_KEY"):
        print("❌ 请先配置 DASHSCOPE_API_KEY 环境变量")
        print("   export DASHSCOPE_API_KEY='your-api-key'")
        exit(1)

    # 初始化真实的多模态 LLM
    # 教学建议:temperature=0 可显著提升 JSON 输出稳定性
    vision_llm = VisionLLM(model_name="qwen-vl-max")

    # 使用真实图片路径(请确保图片存在)
    # 这里使用相对路径示例,请根据你的实际路径修改
    image_paths = [
        "../data/img.png",      # 第一张图片
        "../data/img_1.png",    # 第二张图片(可选)
    ]

    # 查找存在的图片
    test_image = None
    for path in image_paths:
        if os.path.exists(path):
            test_image = os.path.abspath(path)
            print(f"✅ 找到测试图片: {test_image}")
            break

    if not test_image:
        print("❌ 未找到测试图片,请确保以下路径之一存在图片:")
        for p in image_paths:
            print(f"   - {p}")
        print("\n💡 提示:可以将任意图片复制到对应路径进行测试")
        exit(1)

    # 构造 file:// 协议路径
    image_path = f"file://{test_image}"

    print("\n" + "="*50)
    print("🚀 开始多模态识图 + Schema 校验 + 自动修复")
    print("="*50 + "\n")

    try:
        # 调用完整闭环:识图 → 校验 → (失败则)修复 → 再校验
        result = parse_validate_with_repair(
            text=None,           # 首次调用,需要识图
            llm=vision_llm,
            image_path=image_path
        )

        print("="*50)
        print("✅ 最终输出(通过合同校验的 JSON):")
        print("="*50)
        print(json.dumps(result, ensure_ascii=False, indent=2))

    except Exception as e:
        print(f"\n❌ 处理失败: {e}")
        print("💡 可能原因:")
        print("   1. API Key 无效或已过期")
        print("   2. 网络连接问题")
        print("   3. 图片格式不被支持")
        exit(1)

🧰 图片输入治理(上线必做清单)

图片输入治理流水线:上线前必须检查的安全六步 1. 接收 原始图片 2. 格式校验 白名单+拒绝 3. 大小校验 5MB 限制 4. 压缩处理 长边 1600px 5. 缓存命中 SHA256 去重 6. 脱敏+调用 隐私检查+识图 超限 拒绝/压缩 命中 直接返回缓存 治理目标:防攻击(格式/大小) | 控成本(缓存/压缩) | 保合规(隐私脱敏) 白名单拒绝非法格式 | 大小限制防资源耗尽 | 缓存消除重复计算 | 脱敏保护用户隐私

为什么上线必须做输入治理?

防攻击:无格式限制会被恶意上传超大图/非图片格式拖垮服务。
控成本:重复图片不做缓存会导致重复计费;大图不压缩会导致 API 费用飙升。
保合规:人脸/证件/敏感信息直接上传存在隐私泄露风险,必须脱敏。

六步治理做了什么?

1-2 安全准入:格式白名单(只认 jpg/png/webp)+大小限制(5MB),不合规直接拒绝。
3-4 预处理:大图自动压缩/缩放(长边1600px),降低推理耗时和成本。
5-6 优化与合规:内容哈希去重缓存;隐私敏感内容脱敏(打码/裁剪)后上传。

🎯 本节重点

输入治理是多模态的第一道防线。不做治理的接口如同敞开的门,任何人都能塞入超大/非法/敏感图片。
本节代码演示完整的六步流水线:格式校验→大小校验→压缩→缓存→脱敏→调用,让上线更可控、更安全、更便宜。
  • 大小限制:单图建议 < 5MB,超过先压缩/缩放,否则成本与延迟不可控。
  • 格式白名单:jpg/png/webp;不在白名单直接拒绝或转码。
  • 分辨率限制:长边限制(例如 1600px),避免大图推理耗时飙升。
  • 缓存:对图片 bytes 做 hash,重复图片直接复用结构化 JSON 结果(TTL)。
  • 隐私合规:证件/人脸/车牌/隐私屏幕建议先脱敏(裁剪/打码)再上传。
python
# 复制本段到 image_governance_example.py 可直接运行验证
#
# 这段代码在讲什么?(教学导读)
# - 多模态上线第一道防线:输入治理(Input Guardrails)
# - 核心目标:把“任何图片都能进来”的不确定性,变成“可控成本 + 可控风险”的确定性
#
# 你将学到:
# 1) precheck:格式白名单/大小限制(不合规直接拒绝,避免成本失控)
# 2) 分辨率治理:大图建议缩放(否则延迟和 token 成本会飙升)
# 3) hash 缓存:重复图片复用结果(省钱、降低 P95)
# 4) 脱敏占位:生产里常见的人脸/证件/车牌打码通常发生在这层
#
# 目标:precheck(大小/格式/分辨率) → 压缩/缩放 → hash 缓存 → 调用识图 → 返回结果

import hashlib
import os
import json
from dataclasses import dataclass
from typing import Any, Dict, Tuple, Callable

from langchain_community.chat_models import ChatTongyi
from langchain_core.messages import HumanMessage


# -------------------------
# 1) 治理策略配置(可按业务调整)
# -------------------------
# 教学点:
# - 这些阈值应该“配置化”(env/config/feature flag),而不是写死
# - 不同业务的容忍度不同:电商图 vs 身份证图,策略差异巨大
# -------------------------
MAX_BYTES = 5 * 1024 * 1024          # 5MB
ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".webp"}
MAX_LONG_EDGE = 1600                 # 长边 1600px
CACHE: Dict[str, Dict[str, Any]] = {}  # 内存缓存(生产建议用 Redis)


def precheck_image(path: str) -> None:
    """校验大小、格式、分辨率。

    - 格式/大小不合规:直接拒绝(避免成本失控)
    - 分辨率过大:后续会自动缩放
    """
    # 1.1 格式白名单
    ext = os.path.splitext(path)[1].lower()
    if ext not in ALLOWED_EXT:
        raise ValueError(f"不支持的格式: {ext}")

    # 1.2 大小限制:超过直接拒绝(先别急着“帮用户压缩”)
    # 教学解释:
    # - 压缩本身也要 CPU/时间;线上通常要先拒绝,再引导用户上传合规图片
    size = os.path.getsize(path)
    if size > MAX_BYTES:
        raise ValueError(f"图片过大: {size} > {MAX_BYTES}")

    # 1.3 可选:分辨率检查(需要 Pillow)
    # 常见策略:长边超过阈值则缩放;这里只演示“检查”,不直接修改原图
    # try:
    #     from PIL import Image
    #     w, h = Image.open(path).size
    #     if max(w, h) > MAX_LONG_EDGE:
    #         raise ValueError(f"分辨率过大: {w}x{h}")
    # except Exception:
    #     pass  # Pillow 未装就跳过


def sha256_file(path: str) -> str:
    """计算文件 SHA-256,用于缓存键。"""
    # 教学点:
    # - 用文件内容 hash 做 key,可以天然“去重”
    # - 生产环境可以加:TTL + LRU + 最大缓存条数(防止内存爆)
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest()


# -------------------------
# 2) 图片治理演示(仅读取,绝不修改原图)
# -------------------------
def analyze_image_info(path: str) -> Dict[str, Any]:
    """分析图片信息,仅读取元数据,绝不修改原图。
    
    这个函数展示真正的"治理":在不破坏原图的前提下获取信息。
    
    返回:
    - size_bytes: 文件大小
    - dimensions: 图片尺寸
    - format: 图片格式
    - needs_compression: 是否需要压缩
    - compression_ratio: 如果压缩,建议的压缩比例
    """
    try:
        from PIL import Image
        
        # 仅读取图片信息,不做任何修改
        img = Image.open(path)
        w, h = img.size
        size_bytes = os.path.getsize(path)
        
        # 计算是否需要压缩
        needs_compression = max(w, h) > MAX_LONG_EDGE
        compression_ratio = MAX_LONG_EDGE / max(w, h) if needs_compression else 1.0
        
        return {
            "size_bytes": size_bytes,
            "size_mb": round(size_bytes / (1024 * 1024), 2),
            "dimensions": (w, h),
            "format": img.format,
            "needs_compression": needs_compression,
            "compression_ratio": round(compression_ratio, 2),
            "target_dimensions": (int(w * compression_ratio), int(h * compression_ratio)) if needs_compression else (w, h),
            "estimated_compressed_size": round(size_bytes * compression_ratio * compression_ratio / (1024 * 1024), 2) if needs_compression else round(size_bytes / (1024 * 1024), 2)
        }
    except Exception as e:
        return {"error": f"无法分析图片: {e}"}


def simulate_compression_plan(path: str) -> Dict[str, Any]:
    """模拟压缩计划,展示压缩后的效果,但不实际执行压缩。
    
    这个函数展示"规划"的重要性:先评估,再执行。
    """
    info = analyze_image_info(path)
    
    if "error" in info:
        return info
    
    if not info["needs_compression"]:
        return {
            "action": "无需压缩",
            "reason": f"图片尺寸 {info['dimensions']} 在限制范围内",
            "original_info": info
        }
    
    return {
        "action": "建议压缩",
        "original_info": info,
        "compression_plan": {
            "current_size_mb": info["size_mb"],
            "estimated_size_mb": info["estimated_compressed_size"],
            "space_saved_mb": round(info["size_mb"] - info["estimated_compressed_size"], 2),
            "current_dimensions": info["dimensions"],
            "target_dimensions": info["target_dimensions"],
            "compression_ratio": info["compression_ratio"]
        },
        "safety_note": "这只是模拟分析,原图不会被修改。实际压缩时会创建临时文件。"
    }


# -------------------------
# 3) 缓存 + 调用闭环
# -------------------------

@dataclass
class VisionResult:
    """识图返回的结构化结果(示例)"""
    summary: str
    entities: list[str]
    need_more_info: list[str]

    def to_dict(self) -> Dict[str, Any]:
        """转为普通字典,便于 json.dumps 序列化。"""
        return {
            "summary": self.summary,
            "entities": self.entities,
            "need_more_info": self.need_more_info,
        }


class RealVisionCall:
    """真实的多模态识图调用:使用 DashScope 通义千问 Vision 模型。

    需要提前配置环境变量:export DASHSCOPE_API_KEY="your-api-key"
    """

    def __init__(self, model_name: str = "qwen-vl-max"):
        """初始化 Vision LLM。"""
        self.llm = ChatTongyi(model_name=model_name, temperature=0)

    def __call__(self, image_path: str) -> VisionResult:
        """调用多模态模型识别图片并返回结构化结果。

        Args:
            image_path: 图片路径(本地文件路径)

        Returns:
            VisionResult 结构化结果
        """
        # 构造 file:// 协议路径
        file_path = f"file://{os.path.abspath(image_path)}"

        # 构造多模态消息:文本 + 图片
        prompt = (
            "你是识图助手。请分析这张图片,并严格输出 JSON 格式:\n"
            '{"summary": "图片描述", "entities": ["实体1", "实体2"], "need_more_info": []}\n'
            "要求:summary 是图片的整体描述;entities 是图中识别到的关键对象列表;need_more_info 是需要用户补充的信息。"
        )
        message = HumanMessage(
            content=[
                {"text": prompt},
                {"image": file_path},
            ]
        )

        try:
            resp = self.llm.invoke([message])
            # 适配 Tongyi Vision 返回格式
            if isinstance(resp.content, list) and len(resp.content) > 0:
                text = resp.content[0].get("text", "")
            else:
                text = resp.content if isinstance(resp.content, str) else str(resp.content)

            # 解析 JSON 输出
            try:
                obj = json.loads(text)
                return VisionResult(
                    summary=obj.get("summary", "解析失败"),
                    entities=obj.get("entities", []),
                    need_more_info=obj.get("need_more_info", [])
                )
            except json.JSONDecodeError:
                # JSON 解析失败,返回原始文本
                return VisionResult(
                    summary=f"JSON解析失败,原始输出:{text[:100]}",
                    entities=[],
                    need_more_info=["模型输出格式不正确"]
                )
        except Exception as e:
            return VisionResult(
                summary=f"识图调用失败: {str(e)}",
                entities=[],
                need_more_info=["请检查 API Key 和网络连接"]
            )


def cached_vision_call(image_path: str, vision_fn: Callable[[str], VisionResult]) -> Dict[str, Any]:
    """带缓存、治理的识图调用闭环。

    返回:
    - _cache: bool 是否命中缓存
    - result: VisionResult 结构化结果
    - _original_path: 原始路径(用于日志)
    - _governance_info: 图片治理信息(尺寸、格式等)
    """
    # 1) 治理校验
    precheck_image(image_path)

    # 2) 分析图片信息(仅读取,不修改)
    governance_info = analyze_image_info(image_path)
    
    # 3) 缓存键(基于原图)
    cache_key = sha256_file(image_path)
    if cache_key in CACHE:
        return {"_cache": True, "result": CACHE[cache_key].to_dict(), "_original_path": image_path, "_governance_info": governance_info}

    # 4) 调用识图(使用原图,确保安全)
    result = vision_fn(image_path)

    # 5) 存入缓存(生产建议加 TTL)
    CACHE[cache_key] = result

    return {"_cache": False, "result": result.to_dict(), "_original_path": image_path, "_governance_info": governance_info}


# -------------------------
# 4) 可运行验证:图片治理演示(绝对安全)
# -------------------------

if __name__ == "__main__":
    # 检查 API Key
    if not os.environ.get("DASHSCOPE_API_KEY"):
        print("❌ 请先配置 DASHSCOPE_API_KEY 环境变量")
        print("   export DASHSCOPE_API_KEY='your-api-key'")
        exit(1)

    # 初始化真实的多模态识图调用
    vision = RealVisionCall(model_name="qwen-vl-max")
    print("✅ 已初始化 DashScope Vision 模型 (qwen-vl-max)")

    # 使用真实图片路径(请确保这些文件存在)
    real_paths = ["../data/img.png", "../data/img_1.png"]
    
    # 检查文件是否存在
    existing_paths = []
    for p in real_paths:
        if os.path.exists(p):
            existing_paths.append(p)
            print(f"✅ 找到图片: {p}")
        else:
            print(f"⚠️  图片不存在: {p}")
    
    if not existing_paths:
        print("❌ 没有找到可用的图片文件,请确保 ../data/img.png 或 ../data/img_1.png 存在")
        print("💡 提示:可以将真实图片复制到 ../data/ 目录下进行测试")
        exit(1)

    print("\n=== 图片治理演示(仅分析,不修改原图)===")
    
    # 演示图片分析功能
    for p in existing_paths:
        print(f"\n==== 分析 {p} ====")
        analysis = analyze_image_info(p)
        print(json.dumps(analysis, ensure_ascii=False, indent=2))
        
        # 演示压缩计划
        plan = simulate_compression_plan(p)
        print(f"\n压缩计划:")
        print(json.dumps(plan, ensure_ascii=False, indent=2))

    print("\n=== 识图调用演示 ===")
    
    # 第一次调用第一个图片(未命中缓存)
    first_image = existing_paths[0]
    out1 = cached_vision_call(first_image, vision)
    print(f"\n==== {first_image}(首次)====")
    print(json.dumps(out1, ensure_ascii=False, indent=2))

    # 第二次调用同一图片(命中缓存)
    out1_cached = cached_vision_call(first_image, vision)
    print(f"\n==== {first_image}(缓存命中)====")
    print(json.dumps(out1_cached, ensure_ascii=False, indent=2))

    # 如果有第二张图片,调用它(新图,未命中缓存)
    if len(existing_paths) > 1:
        second_image = existing_paths[1]
        out2 = cached_vision_call(second_image, vision)
        print(f"\n==== {second_image}(新图)====")
        print(json.dumps(out2, ensure_ascii=False, indent=2))

    print("\n=== 安全验证 ===")
    print("✅ 原图文件未被修改")
    print("✅ 仅读取图片元数据进行分析")
    print("✅ 缓存基于原图内容,无需临时文件")
    print("✅ 治理信息包含尺寸、格式等关键数据")
    print("✅ 演示代码不会创建或覆盖任何文件")

📈 可观测:让错误可定位、慢点可解释

多模态至少要分段计时:precheckvision_callpost_parse。并且把异常分类成 input/provider/parse/policy。

可观测性三层体系:让错误可定位、慢点可解释 Trace ID 贯穿全链路(每一次请求都有唯一标识) 1. precheck 输入治理计时 2. vision_call 模型调用计时 3. post_parse 结果解析计时 总耗时 / 各阶段 定位慢在哪 错误分类 → 快速定位根因 input_error provider_error parse_error policy_error 可观测产出:结构化日志 + 指标大盘 + 告警规则 每个请求都有迹可循 | 每个异常都能分类 | 每个慢查询都能定位阶段

为什么必须做可观测?

黑盒问题:没有 trace_id,用户报错时根本不知道哪个环节出问题,只能靠猜。
慢查询无处定位:用户说"太慢了",没有分段计时根本不知道是治理慢、模型慢还是解析慢。
错误混杂:input/provider/parse 错误混在一起,无法针对性优化或告警。

三层可观测做了什么?

Trace ID 贯穿:每个请求生成唯一 trace_id,日志里全链路可追踪。
分段计时:precheck / vision_call / post_parse 三阶段分别记录耗时,定位瓶颈。
错误分类:input(输入问题)/provider(模型服务)/parse(解析失败)/policy(策略拒绝),根因秒定。

🎯 本节重点

可观测性是多模态的"眼睛"。没有它,系统就是黑盒,问题只能靠猜。
本节代码演示完整的可观测流水线:Trace ID → 分段计时 → 错误分类 → 结构化日志,让每个请求都有迹可循、每个慢点都能解释、每个错误都能定位。
python
# 复制本段到 observability_example.py 可直接运行验证
#
# 这段代码在讲什么?(教学导读)
# - 多模态系统最大的痛点是“黑盒”:慢/错/不稳定时你不知道发生了什么
# - 可观测的目标:每一次请求都有 trace_id,每一步都有耗时与状态,错误能分类统计
#
# 你将学到:
# 1) Trace:把一次调用拆成 precheck/vision_call/post_parse 三段并记录事件
# 2) 错误分类:input/provider/parse/system 四类,便于告警与优化
# 3) 日志脱敏:不要把原图路径/原始输出全文/敏感标识写进日志
#
# 目标:trace_id + 分段计时(precheck/vision_call/post_parse) + 错误分类 + 日志脱敏 + 完整闭环

import time
import uuid
import json
import hashlib
import os
from dataclasses import dataclass
from typing import Any, Dict, Optional, Callable

from langchain_community.chat_models import ChatTongyi
from langchain_core.messages import HumanMessage


# -------------------------
# 1) Trace 与错误分类(可观测核心)
# -------------------------
class Trace:
    """一次多模态请求的完整追踪器。

    用法:
    - trace = Trace()
    - trace.mark("precheck", True, {"size": 1024})
    - trace.mark("vision_call", False, {"error_type": "provider_error"})
    - print(trace.dump())
    """
    def __init__(self):
        # trace_id 用于串起“一次请求”的全链路(日志/监控/排障)
        self.trace_id = f"t-{uuid.uuid4().hex[:8]}"
        self.t0 = time.time()
        self.events = []

    def mark(self, name: str, ok: bool, meta: Optional[Dict[str, Any]] = None):
        """记录一个事件节点。

        Args:
            name: 阶段名,建议 precheck/vision_call/post_parse
            ok: 是否成功
            meta: 附加信息(不要放敏感内容,如原图/完整识别文本)
        """
        self.events.append({
            "name": name,
            "ok": ok,
            "ms": round((time.time() - self.t0) * 1000),
            "meta": meta or {},
        })

    def dump(self) -> Dict[str, Any]:
        """导出为可日志/上报的 JSON 结构。"""
        return {"trace_id": self.trace_id, "events": self.events}


def classify_error(e: Exception) -> str:
    """把异常映射为可统计的错误类型。

    分类依据:错误信息关键字 + 常见工程场景。
    """
    # 教学点:分类是为了“可统计”,不是为了写得花
    # - 线上看 dashboard:哪个错误类型占比最高?就优先优化它
    s = str(e).lower()
    if "图片" in s or "format" in s or "size" in s or "path" in s:
        return "input_error"
    if "status_code" in s or "request_id" in s or "dashscope" in s or "timeout" in s:
        return "provider_error"
    if "json" in s or "contract" in s or "schema" in s:
        return "parse_error"
    return "system_error"


def safe_meta(meta: Dict[str, Any]) -> Dict[str, Any]:
    """脱敏:去除敏感字段,避免日志泄露隐私。"""
    # 教学点:
    # - 你很容易“为了调试”把原始输出打印出来,结果把隐私/密钥写进日志
    # - 线上日志一般会汇聚到第三方系统,泄露风险更大
    sensitive_keys = {"image_path", "raw_text", "full_output", "request_id"}
    return {k: v for k, v in meta.items() if k not in sensitive_keys}


# -------------------------
# 2) 带可观测的识图闭环(真实 API 调用)
# -------------------------
@dataclass
class VisionResult:
    """识图返回的结构化结果(示例)"""
    summary: str
    entities: list[str]
    need_more_info: list[str]

class RealVisionWithTrace:
    """真实的多模态识图调用:使用 DashScope 通义千问 Vision 模型。

    需要提前配置环境变量:export DASHSCOPE_API_KEY="your-api-key"
    """

    def __init__(self, model_name: str = "qwen-vl-max"):
        """初始化 Vision LLM。"""
        self.llm = ChatTongyi(model_name=model_name, temperature=0)

    def __call__(self, image_path: str, trace: Trace) -> VisionResult:
        """调用多模态模型识别图片并返回结构化结果。

        Args:
            image_path: 图片路径(本地文件路径)
            trace: Trace 追踪器

        Returns:
            VisionResult 结构化结果
        """
        try:
            # 构造 file:// 协议路径
            file_path = f"file://{os.path.abspath(image_path)}"

            # 构造多模态消息
            prompt = (
                "你是识图助手。请分析这张图片,并严格输出 JSON 格式:\n"
                '{"summary": "图片描述", "entities": ["实体1", "实体2"], "need_more_info": []}\n'
                "要求:summary 是图片的整体描述;entities 是图中识别到的关键对象列表;need_more_info 是需要用户补充的信息。"
            )
            message = HumanMessage(
                content=[
                    {"text": prompt},
                    {"image": file_path},
                ]
            )

            resp = self.llm.invoke([message])

            # 适配 Tongyi Vision 返回格式
            if isinstance(resp.content, list) and len(resp.content) > 0:
                text = resp.content[0].get("text", "")
            else:
                text = resp.content if isinstance(resp.content, str) else str(resp.content)

            # 记录成功
            trace.mark("vision_call", True, {"model": "qwen-vl-max"})

            # 解析 JSON 输出
            try:
                obj = json.loads(text)
                return VisionResult(
                    summary=obj.get("summary", "解析失败"),
                    entities=obj.get("entities", []),
                    need_more_info=obj.get("need_more_info", [])
                )
            except json.JSONDecodeError:
                return VisionResult(
                    summary=f"JSON解析失败,原始输出:{text[:100]}",
                    entities=[],
                    need_more_info=["模型输出格式不正确"]
                )

        except Exception as e:
            trace.mark("vision_call", False, {"error": str(e)})
            raise


def precheck_with_trace(image_path: str, trace: Trace) -> None:
    """带可观测的治理校验。"""
    # 校验文件是否存在
    if not os.path.exists(image_path):
        raise ValueError(f"图片不存在: {image_path}")

    # 校验格式
    ext = os.path.splitext(image_path)[1].lower()
    if ext not in {".jpg", ".jpeg", ".png", ".webp"}:
        raise ValueError(f"不支持的格式: {ext}")

    # 校验大小 (5MB)
    size = os.path.getsize(image_path)
    if size > 5 * 1024 * 1024:
        raise ValueError(f"图片过大: {size} > 5MB")

    trace.mark("precheck", True, {"size_kb": size // 1024})


def post_parse_with_trace(result: VisionResult, trace: Trace) -> Dict[str, Any]:
    """带可观测的后处理(JSON 合同校验等)。"""
    # 校验字段
    if not result.summary:
        raise ValueError("contract_invalid: summary is empty")
    if not isinstance(result.entities, list):
        raise ValueError("contract_invalid: entities must be list")

    trace.mark("post_parse", True, {"fields": len(result.entities)})
    return {"summary": result.summary, "entities": result.entities, "need_more_info": result.need_more_info}


def run_vision_with_observability(image_path: str, vision_fn: Callable[[str, Trace], VisionResult]) -> Dict[str, Any]:
    """完整的多模态调用闭环,带可观测。

    返回:
    - trace: 完整追踪信息
    - result: 结构化结果(失败时为 None)
    - error: 异常信息(成功时为 None)
    """
    trace = Trace()
    try:
        precheck_with_trace(image_path, trace)
        result = vision_fn(image_path, trace)
        parsed = post_parse_with_trace(result, trace)
        return {"trace": trace.dump(), "result": parsed, "error": None}
    except Exception as e:
        trace.mark("error", False, {"type": classify_error(e), "msg": str(e)})
        return {"trace": trace.dump(), "result": None, "error": {"type": classify_error(e), "msg": str(e)}}


# -------------------------
# 3) 可运行验证:使用真实图片调用大模型
# -------------------------
if __name__ == "__main__":
    # 检查 API Key
    if not os.environ.get("DASHSCOPE_API_KEY"):
        print("❌ 请先配置 DASHSCOPE_API_KEY 环境变量")
        print("   export DASHSCOPE_API_KEY='your-api-key'")
        exit(1)

    # 初始化真实的多模态识图调用
    vision = RealVisionWithTrace(model_name="qwen-vl-max")
    print("✅ 已初始化 DashScope Vision 模型 (qwen-vl-max)")

    # 使用真实图片路径(请确保这些文件存在)
    image_paths = ["../data/img.png", "../data/img_1.png"]

    # 查找存在的图片
    test_image = None
    for path in image_paths:
        if os.path.exists(path):
            test_image = os.path.abspath(path)
            print(f"✅ 找到测试图片: {test_image}")
            break

    if not test_image:
        print("❌ 未找到测试图片,请确保以下路径之一存在图片:")
        for p in image_paths:
            print(f"   - {p}")
        print("\n💡 提示:可以将任意图片复制到对应路径进行测试")
        exit(1)

    print("\n" + "=" * 50)
    print("🚀 开始多模态识图 + 可观测性追踪")
    print("=" * 50 + "\n")

    # 调用带可观测性的识图闭环
    out = run_vision_with_observability(test_image, vision)

    print("=" * 50)
    print("📊 完整 Trace 与结果:")
    print("=" * 50)
    print(json.dumps(out, ensure_ascii=False, indent=2))

🧪 质量与验收:上线可执行指标

质量验收五维体系:上线前必须达标的可执行指标 质量门禁 上线前必须通过 1. 合同合规率 JSON 解析 ≥99% Schema ≥95% 2. 一致性 同图多次识别 Jaccard ≥0.7 3. 覆盖 关键字段召回 Recall ≥0.85 4. 失败可解释 need_more_info 给出行动建议 5. 安全 敏感内容拦截 人脸/证件/隐私 验收逻辑:五维全部达标 = 可上线;任一维度不达标 = 必须修复后重新验收

为什么需要质量验收体系?

上线风险:没有明确指标就上线,用户遇到 JSON 解析失败、结果不一致、敏感信息泄露等问题时无法及时响应。
优化方向不清:不知道系统哪部分最弱(是格式合规率低还是覆盖率低),资源投入没有针对性。
无法持续监控:没有基线指标,无法判断是否退化,也无法做 A/B 测试对比模型升级效果。

五维验收体系做了什么?

1-2 稳定性:合同合规率确保 JSON 输出稳定;一致性确保同图多次识别结果不漂移。
3-4 有效性:覆盖确保关键信息不漏;失败可解释确保用户体验(失败时给出建议而非报错)。
5 合规性:安全维度确保敏感内容(人脸/证件/隐私屏幕)必须触发拦截或提醒。

🎯 本节重点

质量验收是多模态上线的最后一道关。不是功能跑通就能上线,而是要达到可量化的质量基线。
本节提供五维可执行指标:合规率/一致性/覆盖/失败可解释/安全,每个维度都有具体数值目标(如 JSON 解析 ≥99%),让上线决策有据可依。
  • 合同合规率:JSON 解析成功率 >= 99%,合同校验通过率 >= 95%。
  • 一致性:同图重复 N 次,entities 重叠率(Jaccard)>= 0.7。
  • 覆盖:对小标注集(例如 30~100 张),关键字段召回率要有目标值(比如 >= 0.85)。
  • 失败可解释:失败时 need_more_info 要给出可行动建议(例如“请提供更清晰近景/提高分辨率”)。
  • 安全:敏感内容(人脸/证件/隐私屏幕)必须触发提醒或策略拦截。

🔗 LangChain 在多模态里怎么用?(不抽象)

你可以把多模态能力当成一个"工具函数",然后用 LangChain 做编排: 识图 → 结构化 → 写报告/生成代码/生成表格。 这样你会得到:更清晰的模块边界、更可测试的链路、更好扩展的项目结构。

LangChain 多模态编排架构:为什么用 LangChain 做编排? 多模态工具层 Vision 模型调用 LangChain 编排层 HumanMessage (text + image) 统一消息格式 · 链式调用 · 状态管理 工具注册 提示模板 输出解析 下游应用层 报告/代码/表格 Guardrails 质量门禁层 Schema 校验 · 敏感过滤 · 失败重试 · 降级策略 核心价值:工具可复用 + 链路可编排 + 质量可管控 = 工程化落地 不依赖具体模型供应商 | 统一接口可切换 | 模块化易于测试扩展

为什么用 LangChain 做多模态编排?

解耦模型与业务:LangChain 作为中间层,下游代码不依赖具体模型供应商(DashScope/OpenAI 等可切换)。
标准化接口:HumanMessage 统一格式支持 text + image,不同模型供应商接入方式一致。
可编排链路:识图、解析、生成、质检各步骤可独立测试、组合、复用,不是一锅粥。

LangChain 三层架构做了什么?

工具层封装:把多模态 API 调用封装成标准 Tool,注册到 LangChain 工具库。
编排层调度:用 Chain/LCEL 编排"识图→结构化→生成"流程,支持条件分支、并行、重试。
门禁层管控:Guardrails 层统一做 Schema 校验、敏感信息过滤、失败降级,业务层无感知。

🎯 本节重点

LangChain 是多模态工程化的"粘合剂"。不是替代多模态模型,而是让多模态能力更好地融入业务系统。
本节演示三层架构:工具层(Vision 调用)→ 编排层(消息格式+链式调用)→ 门禁层(质量管控),让多模态从"玩具"变成"生产工具"。

✅ LangChain API 支持多模态吗?

结论:LangChain 本身是“编排层”,它支持用统一的消息格式组织文本 + 图片输入;但是否真正能识图取决于你选用的底层模型/供应商是否支持多模态(Vision)。
直白理解:LangChain 能把图片作为消息的一部分传下去;模型不支持的话,传了也没用。

工程示例:用 LCEL(Runnable 链)把多模态“产品化”

下面这个示例不再重复讲“怎么传图片”,而是展示 LangChain 独有的工程价值: 用一条可组合的 Runnable 链把 Vision → 合同(JSON) → Guardrails → Writer 串起来。 这样每个节点都能单测、能替换、能加重试与 Trace。

LCEL(Runnable)链路代码(示例:通义千问 Vision + 文本模型)

python
import json
import os
import re
from typing import Any, Dict, List, Tuple

from langchain_community.chat_models import ChatTongyi
from langchain_core.messages import HumanMessage
from langchain_core.runnables import RunnableBranch, RunnableLambda, RunnablePassthrough


# =========================
# 这段代码在讲什么?(教学导读)
# =========================
# 目标:把“多模态识图”做成一条可复用、可观测、可降级的工程链路。
#
# 关键工程点:
# 1) LCEL(Runnable)是 LangChain 的“编排语言”:每一步都是可替换节点。
# 2) RunnableBranch 是“降级开关”:Vision 失败就走 OCR/Text 分支。
# 3) with_retry 是“链路级重试”:解析/校验失败统一重试,不把 try/except 写满业务。
# 4) 合同优先:不管 Vision 还是 OCR,最终都输出同一个 JSON 合同,Writer 无感。
#
# 运行前准备:
# - 需要提前配置:export DASHSCOPE_API_KEY="..."
# - 可选启用 LangSmith Trace(课堂讲可观测非常有用):
#     export LANGCHAIN_TRACING_V2=true
#     export LANGCHAIN_API_KEY="你的 LangSmith Key"
#     export LANGCHAIN_PROJECT="vision-writer-demo"


# =========================
# 0) 模型选择与稳定性
# =========================
# - Vision 模型:负责“看图”
# - Text 模型:负责“读 OCR 文本并抽合同”(fallback 路径)
# - Writer 模型:负责“把合同变交付物”(文案/报告/工单都可以换)
#
# 温度建议:temperature=0 有助于结构化输出稳定(更利于 JSON 解析/校验)
vision_llm = ChatTongyi(model_name="qwen-vl-max", temperature=0)
text_llm = ChatTongyi(model_name="qwen-max", temperature=0)
writer_llm = ChatTongyi(model_name="qwen-max", temperature=0)


# =========================
# 1) 输出合同(Schema)
# =========================
# 说明:合同字段越稳定,下游越好写(前端渲染/落库/检索/下游 Writer 都依赖它)
REQUIRED = {"summary", "entities", "need_more_info"}


def _extract_json(text: str) -> str:
    text = (text or "").strip()
    # 常见坑:模型会用 ```json ...``` 包起来,不去掉就无法 json.loads
    if text.startswith("```"):
        text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE)
        text = re.sub(r"\s*```$", "", text)
    m = re.search(r"\{.*\}", text, flags=re.S)
    return m.group(0) if m else text


def _vision_call(inp: Dict[str, Any]) -> Dict[str, Any]:
    """节点1:尝试 Vision。

    返回统一结构,便于 RunnableBranch 决策:
    - ok: 是否成功
    - raw: Vision 原始输出文本
    - error: 失败原因(字符串)
    """
    # 允许手动强制走 OCR 分支(课堂演示时很好用)
    # 说明:线上一般用“错误类型/超时/置信度”触发,这里用 force_ocr 简化讲解
    if inp.get("force_ocr"):
        return {"ok": False, "raw": "", "error": "force_ocr"}

    image_path = inp.get("image_path")
    if not image_path:
        return {"ok": False, "raw": "", "error": "missing_image_path"}

    prompt = (
        "你是识图助手。请严格输出 JSON(不要输出多余文本/代码块):"
        '{"summary": string, "entities": [string], "need_more_info": [string]}'
    )

    try:
        msg = HumanMessage(content=[{"text": prompt}, {"image": image_path}])
        resp = vision_llm.invoke([msg])
        raw = resp.content[0]["text"]
        if not (isinstance(raw, str) and raw.strip()):
            return {"ok": False, "raw": "", "error": "empty_vision_output"}
        return {"ok": True, "raw": raw, "error": ""}
    except Exception as e:
        # 注意:这里不直接抛异常,而是把失败“结构化”出来交给 Branch 决策
        return {"ok": False, "raw": "", "error": f"vision_error:{type(e).__name__}"}


def _parse_contract(v: Dict[str, Any]) -> Dict[str, Any]:
    """节点2:把 Vision 原始文本解析为合同 JSON。

    说明:
    - 只有在 ok=True 的分支才会走到这里
    - 解析失败抛异常,可触发链路重试
    """
    # 解析失败抛异常:交给 with_retry 做统一重试
    return json.loads(_extract_json(v.get("raw", "")))


def _ocr_text_to_contract(inp: Dict[str, Any]) -> Dict[str, Any]:
    """降级分支:OCR/Text → 合同 JSON。

    实战里通常是:
    - 先用 OCR 引擎把图片转成文字(比如 PaddleOCR/Tesseract/厂商 OCR)
    - 再把 ocr_text 喂给文本 LLM 生成同一个 JSON 合同(保持“合同口径一致”)

    这里为了示例可运行性:假设你已经拿到了 ocr_text。
    """
    # 输入约定:ocr_text 是 OCR 输出的纯文本(可由 PaddleOCR/Tesseract/厂商 OCR 产出)
    ocr_text = (inp.get("ocr_text") or "").strip()
    if not ocr_text:
        raise ValueError("missing_ocr_text")

    prompt = (
        "你是信息抽取助手。请根据以下 OCR 文本,严格输出 JSON(不要输出多余文本/代码块):"
        '{"summary": string, "entities": [string], "need_more_info": [string]}\n\n'
        f"OCR_TEXT=\n{ocr_text}"
    )
    resp = text_llm.invoke(prompt)
    raw = resp.content if isinstance(resp.content, str) else resp.content[0].get("text", "")
    return json.loads(_extract_json(raw))


def _guard_contract(obj: Dict[str, Any]) -> Dict[str, Any]:
    """节点3:Guardrails(合同层)— schema 校验 + 轻量脱敏(示例)。"""
    # 2.1 合同校验:字段缺失/类型错就当失败(不要让脏数据进入下游)
    miss = [k for k in REQUIRED if k not in obj]
    if miss:
        raise ValueError(f"contract_missing={miss}")
    if not isinstance(obj.get("entities"), list):
        raise ValueError("entities must be list")
    if not isinstance(obj.get("need_more_info"), list):
        raise ValueError("need_more_info must be list")

    # 2.2 合规:轻量脱敏(示例)
    # 生产环境建议:脱敏策略配置化 + 记录命中原因(用于审计)
    def clean(s: Any) -> str:
        s = "" if s is None else str(s)
        s = re.sub(r"\b\d{17}[0-9Xx]\b", "[REDACTED_ID]", s)
        s = re.sub(r"\b1\d{10}\b", "[REDACTED_PHONE]", s)
        s = re.sub(r"[\w.+-]+@[\w-]+\.[\w.-]+", "[REDACTED_EMAIL]", s)
        return s

    out = dict(obj)
    out["summary"] = clean(out.get("summary"))
    out["entities"] = [clean(x) for x in out.get("entities", [])]
    out["need_more_info"] = [clean(x) for x in out.get("need_more_info", [])]
    return out


def _writer_call(contract: Dict[str, Any]) -> str:
    """节点4:Writer 产出交付物(纯文本)。"""
    prompt = (
        "你是运营文案专家。\n"
        "请根据以下 JSON 生成一段 80 字以内的商品介绍,强调卖点。\n"
        "输出必须是纯文本,不要 JSON,不要 Markdown,不要列表符号,不要链接,不要联系方式。\n\n"
        f"JSON={json.dumps(contract, ensure_ascii=False)}"
    )
    resp = writer_llm.invoke(prompt)
    out = resp.content if isinstance(resp.content, str) else resp.content[0].get("text", "")
    return (out or "").strip()


def _guard_deliverable(text: str) -> str:
    """节点5:Guardrails(交付物层)— 长度/敏感/危险指令拦截(示例)。"""
    # 最小门禁:空/太长/危险指令/PII
    if not text:
        raise ValueError("deliverable_empty")
    if len(text) > 80:
        raise ValueError(f"deliverable_too_long={len(text)}")
    if re.search(r"\b(import\s+os|os\.system|subprocess|rm\s+-rf)\b", text, flags=re.I):
        raise ValueError("deliverable_unsafe")
    if re.search(r"\b1\d{10}\b", text) or re.search(r"[\w.+-]+@[\w-]+\.[\w.-]+", text):
        raise ValueError("deliverable_pii")
    return text


# =========================
# 3) 把节点串成链路:RunnableBranch 做降级
# =========================
# 关键:Runnable 链路(LCEL) + RunnableBranch(失败降级)
# - 主分支:Vision → parse → guard
# - 降级分支:OCR/Text → guard
# - 分支合流后:writer → deliverable_guard
# 说明:contract_chain 专注“生成合规合同”,后续 Writer 只依赖合同
contract_chain = (
    RunnablePassthrough()
    | RunnableLambda(_vision_call)
    | RunnableBranch(
        # 条件:Vision 成功就走主分支
        (lambda v: bool(v.get("ok")), RunnableLambda(_parse_contract)),
        # 默认分支:Vision 失败 → 回到原始输入,用 OCR/Text 生成合同
        # 教学点:这就是“降级”。让链路有结果,而不是报错退出。
        RunnablePassthrough() | RunnableLambda(_ocr_text_to_contract),
    )
    | RunnableLambda(_guard_contract)
)

chain = (
    RunnablePassthrough()
    | contract_chain
    | RunnableLambda(_writer_call)
    | RunnableLambda(_guard_deliverable)
).with_retry(stop_after_attempt=3)


if __name__ == "__main__":
    image_path = f"file://{os.path.abspath('eagle.png')}"

    # 正常情况:直接走 Vision
    print(chain.invoke({"image_path": image_path}))

    # 课堂演示:强制走 OCR 降级分支(只要提供 ocr_text 即可)
    # 说明:在课堂上你可以先演示 Vision 版本,再演示“Vision 挂了也不影响交付”的 fallback。
    # print(chain.invoke({"force_ocr": True, "ocr_text": "Apple iPhone 15 Pro 128GB\n价格 7999\n钛金属"}))

你应该关注的新知识点(这段才是 LangChain 的价值): 1) RunnableBranch = 生产级降级开关:Vision 失败(或你主动 force_ocr)时自动切到 OCR/Text 分支,业务层不用写 if/else;
2) 合同口径一致:不管走 Vision 还是 OCR,最终都输出同一个 JSON 合同(字段一致),下游 Writer 无感;
3) with_retry 是链路级能力:解析失败/校验失败可以统一重试;打开 LangSmith 后还能把每个节点 Trace 出来,定位失败点。

建议的拆分方式

  • Vision Step(识图):返回 JSON(摘要/实体/风险/待补充)
  • Text Step(生成):基于 JSON 生成最终产物(教程/报告/邮件/代码)
  • Guardrails(安全与质量):校验 JSON schema、过滤敏感字段、失败重试与降级

🧩 端到端示例:Vision(JSON) → Writer(交付物) → Guardrails

这个示例强调“工程拆分”:第一段只负责把图片变成稳定 JSON 合同;第二段把合同变成可交付产物(文案/报告/工单)。中间用 Guardrails 做质量门禁。

python
import json
import os
import re
from typing import Any, Dict, List, Tuple

from langchain_community.chat_models import ChatTongyi
from langchain_core.messages import HumanMessage


# =========================
# 这段端到端示例在讲什么?(教学导读)
# =========================
# 目标:把多模态系统拆成三个独立模块:
# - Vision:只负责“看图 → 输出合同(JSON)”
# - Writer:只负责“合同(JSON) → 交付物(文本/报告/工单)”
# - Guardrails:夹在中间做质量门禁(schema 校验、敏感过滤、失败重试/降级)
#
# 为什么要拆?
# - Vision 模型输出再不稳定,只要合同稳定,下游就稳定。
# - Writer 可以频繁迭代(换风格/换模板/换输出类型),不影响 Vision。
# - Guardrails 统一收口风险:把“不确定性”挡在业务前。

vision_llm = ChatTongyi(model_name="qwen-vl-max", temperature=0)
writer_llm = ChatTongyi(model_name="qwen-max", temperature=0)


REQUIRED = {"summary", "entities", "need_more_info"}


def _extract_json(text: str) -> str:
    text = (text or "").strip()
    # 常见坑:模型包 ```json``` / 或前后夹解释文字
    if text.startswith("```"):
        text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE)
        text = re.sub(r"\s*```$", "", text)
    m = re.search(r"\{.*\}", text, flags=re.S)
    return m.group(0) if m else text


def validate_contract(obj: Dict[str, Any]) -> Tuple[bool, str]:
    # 合同校验:字段缺失/类型错误都视为失败
    miss = [k for k in REQUIRED if k not in obj]
    if miss:
        return False, f"missing={miss}"
    if not isinstance(obj.get("entities"), list):
        return False, "entities must be list"
    if not isinstance(obj.get("need_more_info"), list):
        return False, "need_more_info must be list"
    return True, "ok"


def guard_contract(obj: Dict[str, Any]) -> Tuple[Dict[str, Any], List[str]]:
    """Guardrails(合同层):schema 校验 + 敏感字段过滤(最小示例)。"""
    issues: List[str] = []

    ok, reason = validate_contract(obj)
    if not ok:
        issues.append(f"schema:{reason}")
        return obj, issues

    def _clean_text(s: Any) -> str:
        s = "" if s is None else str(s)
        s = re.sub(r"\b\d{17}[0-9Xx]\b", "[REDACTED_ID]", s)
        s = re.sub(r"\b1\d{10}\b", "[REDACTED_PHONE]", s)
        s = re.sub(r"[\w.+-]+@[\w-]+\.[\w.-]+", "[REDACTED_EMAIL]", s)
        return s

    obj = dict(obj)
    obj["summary"] = _clean_text(obj.get("summary"))
    obj["entities"] = [_clean_text(x) for x in obj.get("entities", [])]
    obj["need_more_info"] = [_clean_text(x) for x in obj.get("need_more_info", [])]
    return obj, issues


def guard_deliverable(text: str, max_chars: int = 80) -> Tuple[bool, str]:
    """Guardrails(交付物层):长度/敏感/注入等检查(最小示例)。"""
    t = (text or "").strip()
    if not t:
        return False, "empty"
    if len(t) > max_chars:
        return False, f"too_long:{len(t)}"
    if re.search(r"\b(ssh-rsa|BEGIN\s+PRIVATE\s+KEY|AKIA[0-9A-Z]{16})\b", t, flags=re.I):
        return False, "secret_like"
    if re.search(r"\b(import\s+os|os\.system|subprocess|rm\s+-rf)\b", t, flags=re.I):
        return False, "unsafe_instruction"
    if re.search(r"\b1\d{10}\b", t) or re.search(r"[\w.+-]+@[\w-]+\.[\w.-]+", t):
        return False, "pii"
    return True, "ok"


def vision_to_contract(image_path: str, max_retries: int = 2) -> Dict[str, Any]:
    """Vision → JSON 合同,并在 Guardrails 层做校验/重试/降级。"""
    # 教学点:
    # - max_retries 控制成本与可用性权衡
    # - 生产建议把失败原因写入 need_more_info 或日志(便于告警/复盘)
    last_err = ""
    for attempt in range(max_retries + 1):
        prompt = (
            "你是识图助手。请严格输出 JSON(不要输出多余文本/代码块):"
            '{"summary": string, "entities": [string], "need_more_info": [string]}'
        )
        msg = HumanMessage(content=[{"text": prompt}, {"image": image_path}])
        resp = vision_llm.invoke([msg])
        text = resp.content[0]["text"]

        try:
            obj = json.loads(_extract_json(text))
        except Exception as e:
            last_err = f"json_parse:{e}"
            continue

        obj, issues = guard_contract(obj)
        if not issues:
            return obj
        last_err = ";".join(issues)

    return {
        "summary": "",
        "entities": [],
        "need_more_info": ["contract_invalid", last_err],
        "_degraded": True,
    }


def contract_to_deliverable(contract: Dict[str, Any], max_retries: int = 1) -> str:
    """JSON 合同 → 交付物,并在 Guardrails 层做校验/重试/降级。"""
    # 教学点:
    # - 交付物也要做门禁(长度、PII、危险指令),避免“模型胡写”直接对外发布
    last_err = ""
    for attempt in range(max_retries + 1):
        prompt = (
            "你是运营文案专家。\n"
            "请根据以下 JSON 生成一段 80 字以内的商品介绍,强调卖点。\n"
            "输出必须是纯文本,不要 JSON,不要 Markdown,不要列表符号,不要链接,不要联系方式。\n\n"
            f"JSON={json.dumps(contract, ensure_ascii=False)}"
        )
        resp = writer_llm.invoke(prompt)
        out = resp.content if isinstance(resp.content, str) else resp.content[0].get("text", "")
        ok, reason = guard_deliverable(out, max_chars=80)
        if ok:
            return out.strip()
        last_err = reason

    return "(生成失败:输出未通过 Guardrails 校验)"


if __name__ == "__main__":
    image_path = f"file://{os.path.abspath('eagle.png')}"
    contract = vision_to_contract(image_path)
    deliverable = contract_to_deliverable(contract)
    print("contract=", json.dumps(contract, ensure_ascii=False, indent=2))
    print("deliverable=", deliverable)

🎥 视频模态操作(扩展)

视频多模态在工程里通常不会“直接把整段视频丢给模型”,而是做拆解视频 = 关键帧(视觉) + 音频(语音)。 下面给你两条最常用、最容易落地的路径。

视频多模态:双通路架构(推荐上线形态) Video.mp4 10~60s Path A: Key Frames frames=6~12 Path B: Audio → ASR whisper / vendor asr Vision Model → JSON summary/scenes/actions Text LLM → JSON topic/key_points 建议:默认走 A(画面为主),A 失败/太慢/成本过高 → 降级到 B(语音为主)
✅ 上线要点(让它稳定 + 可控成本)
1) 帧数控制:默认 6 帧;需要更高覆盖再升到 12 帧。帧数翻倍 = 成本/延迟大概率翻倍。
2) 采样策略:均匀采样(简单稳)→ 场景变化采样(相邻帧差异大才取,减少冗余)→ 页面/字幕变化优先(教学/演示视频更准)。
3) 降级策略:关键帧路线失败(抽帧失败/输出不稳定/超时)→ 自动切音频转写路线,保证“有结果”。
4) 合同优先:两条路线最终都应该输出 JSON 合同,便于复用到报告/工单/检索。

✅ 落地步骤(照做即可跑通)

  1. 准备一个短视频:先用 10~60 秒的视频(mp4 最稳),文件名例如 sample.mp4 放在你运行脚本的目录下。
  2. 选路线:画面为主选“方案 A 关键帧”;讲解为主选“方案 B 音频转写”;不确定就先 A。
  3. 装依赖并验证:按下面“依赖安装”执行;确认 ffmpeg -version 能输出版本(方案 B 必须)。
  4. 复制代码到本地 .py 运行:把下面的代码块复制到 video_frames.pyvideo_audio.py,按注释修改 video_path
  5. 验证输出:你应该能看到 JSON(或 _raw 原始输出)。若不是 JSON,先看“常见报错与排查”。

🧩 依赖安装(按你的环境选)

共同前提(两种方案都需要):已配置通义 DashScope Key(例如 export DASHSCOPE_API_KEY="你的Key")。
方案 A(关键帧):pip install opencv-python
方案 B(音频转写):pip install -U openai-whisper
ffmpeg(方案 B 必须): macOS:brew install ffmpeg(慢)或快速安装:curl -L https://evermeet.cx/ffmpeg/getrelease/ffmpeg/zip -o ffmpeg.zip && unzip ffmpeg.zip && mv ffmpeg ~/ffmpeg
Linux:apt/yum install ffmpeg
Windows:建议用 choco install ffmpeg 或下载官方压缩包并加入 PATH

⚠️ 常见报错与排查(跑不起来先看这里)

  • OpenCV 打不开视频:优先检查路径;Windows 注意反斜杠与空格;不行就先用 mp4(H.264) 格式重编码。
  • ffmpeg 相关错误:99% 是没装或 PATH 未生效;先在终端执行 ffmpeg -version。如果 PyCharm 中报错,尝试快速安装:curl -L https://evermeet.cx/ffmpeg/getrelease/ffmpeg/zip -o ffmpeg.zip && unzip ffmpeg.zip && mv ffmpeg ~/ffmpeg
  • Whisper 很慢:CPU 转写慢是正常的;先用更短视频,或改 whisper.load_model("tiny")
  • 模型输出不是 JSON:先确认提示词含“严格输出 JSON”;必要时保留程序端提取 JSON 的容错逻辑。
  • 临时文件残留:方案 A 会生成临时帧,方案 B 会生成临时音频;生产建议写到固定临时目录并保证清理。

🔒 合规与隐私(生产必看)

  • 敏感信息:身份证/人脸/车牌/屏幕隐私等,建议先本地脱敏(打码/裁剪)再处理。
  • 上传与存储:上传视频要做鉴权、大小限制、类型白名单;临时帧/音频要及时清理。
  • 日志:避免在日志里打印完整转录全文、帧图路径或任何敏感内容。

方案 A:抽取关键帧(视觉内容为主)

思路:从视频按间隔抽几张帧(例如 6 张),把这些帧当成“多张图片”一起发给多模态模型。 这样能覆盖大部分“场景/动作/页面变化”的理解需求。

python
# 依赖:pip install opencv-python
#
# 教学导读:这段在做“关键帧路线”的最小闭环
# 1) 用 OpenCV 从视频抽取 N 张关键帧(默认 6 张,成本/延迟可控)
# 2) 把这些帧作为多张图片一次性喂给 Vision 模型(覆盖场景变化)
# 3) 输出结构化 JSON(summary/scenes/actions)供下游消费
#
# 生产建议:
# - 帧图存储到临时目录,并确保 finally 清理
# - 对超大视频做时长/分辨率限制,避免 OOM/耗时过长

import os
import json
import cv2
from langchain_community.chat_models import ChatTongyi
from langchain_core.messages import HumanMessage


def extract_frames(video_path: str, frame_count: int = 6) -> list[str]:
    # 1) 打开视频文件
    cap = cv2.VideoCapture(video_path)
    if not cap.isOpened():
        raise ValueError(f"无法打开视频:{video_path}")

    total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
    if total <= 0:
        raise ValueError("视频帧数为 0")

    # 2) 均匀采样:用 step 控制抽帧间隔
    # 更高级做法:检测“场景变化”再抽帧(减少冗余,提高有效信息密度)
    step = max(1, total // frame_count)
    paths: list[str] = []

    for idx in range(0, total, step):
        # 3) 跳到指定帧位置读取
        cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
        ok, frame = cap.read()
        if not ok:
            continue
        # 4) 写出临时图片文件(生产建议写到安全的 tmp 目录)
        out = f"_tmp_frame_{len(paths)}.jpg"
        cv2.imwrite(out, frame)
        paths.append(out)
        if len(paths) >= frame_count:
            break

    cap.release()
    return paths


def analyze_video_by_frames(video_path: str) -> dict:
    # 抽帧
    frames = extract_frames(video_path, frame_count=6)
    llm = ChatTongyi(model_name="qwen-vl-max", temperature=0)

    prompt = (
        "你将看到一个视频的关键帧序列。请综合这些帧推断视频内容,并严格输出 JSON:\n"
        '{"summary": string, "scenes": [string], "actions": [string]}'
    )

    # 把多张图片拼到同一次 HumanMessage 里
    content = [{"text": prompt}]
    for p in frames:
        content.append({"image": f"file://{os.path.abspath(p)}"})

    resp = llm.invoke([HumanMessage(content=content)])

    # 清理临时帧
    # 常见坑:异常中断会残留临时文件;生产建议用 try/finally
    for p in frames:
        try:
            os.remove(p)
        except OSError:
            pass

    text = resp.content[0]["text"]
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        return {"_raw": text}


if __name__ == "__main__":
    video_path = "sample.mp4"  # 替换为你的视频路径
    print(analyze_video_by_frames(video_path))

方案 B:抽取音频并转写(语音内容为主)

思路:把视频里的音频抽出来做 ASR(语音转文字),再把文字交给文本模型做结构化总结。 适合讲课/会议/口播类视频。

python
# -*- coding: utf-8 -*-
# 依赖:pip install -U openai-whisper
# 说明:Whisper 依赖本机 ffmpeg,请先安装 ffmpeg
#
# 教学导读:这段在做“音频路线”的最小闭环
# 1) 用 ffmpeg 从视频提取音频(wav, 16kHz, 单声道)
# 2) Whisper 做 ASR(语音转文字)
# 3) 把转录文本交给文本 LLM 输出结构化 JSON(topic/key_points/summary)
#
# 为什么这条路线是 fallback?
# - 画面信息不重要/或抽帧成本过高时,用语音就能覆盖主要信息
# - 当 Vision 输出不稳定时,ASR+Text 往往更稳

import os
import json
import subprocess
import whisper
from langchain_community.chat_models import ChatTongyi
from langchain_core.messages import HumanMessage


def get_ffmpeg_path():
    """获取 ffmpeg 路径"""
    # 教学点:很多同学“代码没问题但就是跑不起来”,原因 99% 是 ffmpeg 不在 PATH
    # 检查系统路径中的 ffmpeg
    try:
        subprocess.run(["ffmpeg", "-version"], capture_output=True, check=True)
        return "ffmpeg"
    except (subprocess.CalledProcessError, FileNotFoundError):
        pass
    
    # 检查用户目录中的 ffmpeg
    home_dir = os.path.expanduser("~")
    ffmpeg_path = os.path.join(home_dir, "ffmpeg")
    try:
        subprocess.run([ffmpeg_path, "-version"], capture_output=True, check=True)
        return ffmpeg_path
    except (subprocess.CalledProcessError, FileNotFoundError):
        pass
    
    return None


def extract_audio(video_path: str, audio_path: str = "_tmp_audio.wav") -> str:
    """从视频中提取音频"""
    ffmpeg_path = get_ffmpeg_path()
    if not ffmpeg_path:
        raise RuntimeError(
            "ffmpeg 未安装!请安装 ffmpeg:\n"
            "macOS: brew install ffmpeg\n"
            "Ubuntu: sudo apt install ffmpeg\n"
            "Windows: 下载 ffmpeg 并添加到 PATH\n\n"
            "快速安装方案:\n"
            "  curl -L https://evermeet.cx/ffmpeg/getrelease/ffmpeg/zip -o ffmpeg.zip\n"
            "  unzip ffmpeg.zip && mv ffmpeg ~/ffmpeg"
        )
    
    # 使用 subprocess 直接调用 ffmpeg
    # 参数说明:
    # -ac 1:单声道(更省资源)
    # -ar 16000:16k 采样率(ASR 常用)
    # -y:覆盖输出文件(避免交互确认卡住脚本)
    try:
        subprocess.run([
            ffmpeg_path, '-i', video_path, 
            '-ac', '1', '-ar', '16000', 
            '-y', audio_path
        ], capture_output=True, check=True)
    except subprocess.CalledProcessError as e:
        raise RuntimeError(f"音频提取失败: {e.stderr.decode() if e.stderr else str(e)}")
    
    return audio_path


def transcribe(audio_path: str) -> str:
    """转录音频为文字"""
    # 教学点:Whisper 默认模型越大越慢;课堂演示可先用 tiny/base
    # 设置 ffmpeg 路径环境变量,让 whisper 能找到 ffmpeg
    ffmpeg_path = get_ffmpeg_path()
    if ffmpeg_path and ffmpeg_path != "ffmpeg":
        # 如果是自定义路径,添加到 PATH
        current_path = os.environ.get('PATH', '')
        os.environ['PATH'] = f"{os.path.dirname(ffmpeg_path)}:{current_path}"
    
    model = whisper.load_model("base")
    r = model.transcribe(audio_path)
    return (r.get("text") or "").strip()


def analyze_video_by_audio(video_path: str) -> dict:
    """分析视频内容(通过音频转录)"""
    # 1) 抽音频
    audio_path = extract_audio(video_path)
    # 2) ASR 转写
    text = transcribe(audio_path)
    
    # 清理临时文件(生产建议 finally)
    try:
        os.remove(audio_path)
    except OSError:
        pass

    # 使用 LLM 分析转录内容
    llm = ChatTongyi(model_name="qwen-max", temperature=0)
    prompt = (
        "请根据以下视频语音转录内容做结构化总结,并严格输出 JSON:\n"
        f"转录内容:{text}\n"
        '{"summary": string, "key_points": [string], "topic": string}'
    )

    resp = llm.invoke([HumanMessage(content=prompt)])
    out = resp.content if isinstance(resp.content, str) else resp.content[0].get("text", "")
    
    try:
        return json.loads(out)
    except json.JSONDecodeError:
        return {"transcript": text, "_raw": out}


if __name__ == "__main__":
    video_path = "lecture.mp4"  # 替换为你的视频路径
    
    # 检查 ffmpeg
    if not get_ffmpeg_path():
        print("❌ ffmpeg 未安装!")
        print("快速安装:")
        print("  curl -L https://evermeet.cx/ffmpeg/getrelease/ffmpeg/zip -o ffmpeg.zip")
        print("  unzip ffmpeg.zip && mv ffmpeg ~/ffmpeg")
        exit(1)
    
    try:
        result = analyze_video_by_audio(video_path)
        print("✅ 视频分析完成:")
        print(json.dumps(result, ensure_ascii=False, indent=2))
    except Exception as e:
        print(f"❌ 分析失败:{e}")
        exit(1)

🧯 常见坑(非常工程)

📝 本章小结

🎓 你学到了什么?

  • 多模态基础形态:text + image 一次请求完成推理
  • 落地关键:结构化输出(JSON)而不是纯文本
  • LangChain 的价值:把识图能力变成可复用模块并编排进完整链路
← 上一章 下一章 →