LangGraph LangChain AI 约 23 分钟

13 | LangGraph v1 实战:content_blocks 让文本×图片同场:多模态输入输出实战

在第 12 篇里,我们用 DeepSeek 的推理模型 + LangChain v1 展示了一个完整的链路:

LangGraph LangChain AI 13 | LangGraph v1 实战:content_blocks 让文本×图片同场:多模态输入输出实战
13 | LangGraph v1 实战:content_blocks 让文本×图片同场:多模态输入输出实战

一、从 DeepSeek 文本推理,走向多模态世界

在第 12 篇里,我们用 DeepSeek 的推理模型 + LangChain v1 展示了一个完整的链路:

但还有一个没展开的关键能力:多模态

现实世界里,用户的问题越来越少是「纯文本」:

如果我们还停留在「字符串 in / 字符串 out」的视角,多模态就会变成一堆 provider 各自为战的 JSON 格式,工程体验极差。

LangChain v1 的标准 content_blocks,正好给了我们一个统一视角:

这一篇我们聚焦三件事:

  1. 用一个极简例子,把「多模态输入」的 content_blocks 结构讲清楚;
  2. 用一个可运行 Demo,展示「图片 → deepseek-ocr(Ollama) → 文本 → DeepSeek 文本/推理模型」这一条组合链路;
  3. 用一个贴近业务的场景(报表截图分析),演示如何把文本 + 图片整合进一条稳定的调用链路。

说明:现在推荐使用 DeepSeek 的新模型(如第 09 篇中通过 ChatDeepSeek 调用的模型),它们本质上都是“文本/推理模型”,而不是原生多模态。
在本篇中,我们先用“标准 content_blocks 多模态接口”讲清抽象;
然后落地到一个更实用的组合:Ollama + deepseek-ocr 做图片 OCR,DeepSeek 文本模型负责推理
好处是:既不依赖闭源多模态 API,又能在工程上保持标准 content_blocks 结构。


二、content_blocks 多模态输入:一条消息里同时带文字和图片

先统一一个概念:在 LangChain v1 里,多模态输入的本质就是「消息内容是若干 content_blocks 的列表」

以一个典型的「文本 + 图片」问题为例:

用户:

  • 文本:帮我看看这张图里的销售折线有没有明显异常;
  • 图片:某运营后台的「近 30 天 GMV 折线图」截图。

2.1 理想世界:底层模型原生支持多模态

如果底层模型原生支持图片输入,最直接的方式当然是直接给它「文本 + 图片」:

from langchain.chat_models import init_chat_model
from langchain_core.messages import HumanMessage

model = init_chat_model(
    "your-multimodal-model-id",  # 示例:任意支持多模态的模型
    output_version="v1",
)

def build_multimodal_message(image_base64: str) -> HumanMessage:
    return HumanMessage(
        content=[
            {
                "type": "text",
                "text": "请根据下面这张图,判断近 30 天销售是否有明显异常,并用中文给出结论和可能原因。",
            },
            {
                "type": "image",
                "base64": image_base64,
                "mime_type": "image/png",
            },
        ]
    )

LangChain 会负责把这份 content_blocks 转成 provider 原生格式,我们只维护这一份标准结构即可。

2.2 现实落地:DeepSeek 新模型 + Ollama + deepseek-ocr(先 OCR 再推理)

如果你和我一样,希望:

那更务实的方案是:

在进 LLM 之前先把图片丢给 deepseek-ocr,拿到纯文本;
再把「用户说明 + OCR 文本」一起组织成 content_blocks 交给 DeepSeek 文本模型。

示意代码(与本篇配套的 ch13/demo_multimodal_content_blocks.py 一致):

import base64
import os
from pathlib import Path

from langchain_ollama import ChatOllama
from langchain_deepseek import ChatDeepSeek
from langchain_core.messages import HumanMessage

def load_image_as_base64(path: str | Path) -> str:
    p = Path(path)
    with p.open("rb") as f:
        return base64.b64encode(f.read()).decode("utf-8")

def ocr_image_with_ollama(path: str | Path) -> str:
    image_b64 = load_image_as_base64(path)

    base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
    ocr_model_id = os.getenv("OCR_MODEL", "deepseek-ocr")
    prompt = os.getenv(
        "OCR_PROMPT",
        "请逐行识别图片中的文字,保持原有行结构,只输出纯文本,不要额外解释。",
    )

    ocr_model = ChatOllama(
        model=ocr_model_id,
        base_url=base_url,
        temperature=0,
        output_version="v1",
    )

    msg = HumanMessage(
        content=[
            {"type": "text", "text": prompt},
            {
                "type": "image",
                "base64": image_b64,
                "mime_type": "image/png",
            },
        ]
    )

    resp = ocr_model.invoke([msg])
    # 简单起见,这里直接用 text 字段;也可以从 resp.content_blocks 里抽取 text 块
    return resp.text.strip()

llm = ChatDeepSeek(
    model=os.getenv("DEEPSEEK_MODEL", "deepseek-reasoner"),
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    api_base=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
    temperature=0.2,
    output_version="v1",
)

def build_message_from_ocr(image_path: str | Path, user_note: str) -> HumanMessage:
    ocr_text = ocr_image_with_ollama(image_path)

    system_text = (
        "你是一名电商运营分析师,擅长解读 GMV 等业务指标的趋势。\n"
        "给你一张报表截图和用户补充说明,请用中文输出:\n"
        "1)整体趋势概述;2)明显异常点;3)可能原因;4)后续建议。"
    )

    return HumanMessage(
        content=[
            {"type": "text", "text": system_text},
            {"type": "text", "text": f"用户补充说明:{user_note}"},
            {
                "type": "text",
                "text": f"截图 OCR 识别结果:\n{ocr_text}",
            },
        ]
    )

def demo_ocr_pipeline():
    msg = build_message_from_ocr("examples/gmv_trend.png", "最近 7 天做了两次大促")
    resp = llm.invoke([msg])
    print("标准 content_blocks:", resp.content_blocks)

这里有几个设计点:

2.3 用一张图总结整个链路

用一张简单的流程图,把这一条「图片 → deepseek-ocr → 文本 → DeepSeek 模型」的链路串起来:

graph LR
    U[用户请求<br/>文本 + 图片] -->|上传图片| OCR[ChatOllama<br/>deepseek-ocr]
    OCR -->|content_blocks 提取 text 块| TXT[OCR 文本]
    U -->|用户补充说明 text 块| TXT
    TXT -->|组装成标准 content_blocks| DS[ChatDeepSeek<br/>DeepSeek 文本/推理模型]
    DS -->|response.content_blocks| OUT[推理过程 + 最终回答]

    OUT --> LOG[日志 / 审计 / 可视化]

从实现角度看:


三、多模态输出:统一解析文本 + 图片的标准结构

输入搞定之后,我们再看输出。

在多模态场景下,模型的响应可能是:

response = model.invoke("画一张简单的增长曲线图,并简单解释含义。")
print(response.content_blocks)

# 可能的输出结构(示意)
# [
#   {"type": "text", "text": "这张图展示了一个稳定增长的趋势……"},
#   {"type": "image", "base64": "...", "mime_type": "image/jpeg"},
# ]

为了方便消费多模态输出,我们通常会写一个小的「块级路由器」:

from typing import List, Dict, Any

def split_multimodal_output(blocks: List[Dict[str, Any]]):
    """把 content_blocks 拆成文本列表 + 图片列表,便于分别处理。"""
    texts = []
    images = []

    for block in blocks:
        if block.get("type") == "text":
            texts.append(block.get("text") or "")
        elif block.get("type") == "image":
            images.append(block)

    return texts, images

def demo_multimodal_output():
    response = model.invoke("画一张简单的增长曲线图,并简单解释含义。")
    texts, images = split_multimodal_output(response.content_blocks)

    print("文本部分:")
    print("\n".join(texts))

    print("\n图片数量:", len(images))
    for idx, img in enumerate(images):
        print(f"第 {idx+1} 张图片 mime_type={img.get('mime_type')}, "
              f"base64 前 50 字符={img.get('base64', '')[:50]}...")

注意几个实践经验:


四、实战场景:报表截图 + 文本补充,一条链路搞定

我们把前面的小段代码拼成一个更真实的场景——运营同学上传报表截图 + 辅助说明,让模型给出分析结论。

4.1 业务需求拆解

需求大致长这样:

  1. 前端上传一张「近 30 天 GMV 折线图」;
  2. 用户附加一段说明文本,例如「最近 7 天我们做了两次大促,帮忙分析一下整体趋势和异常点」;
  3. 模型需要:
    • 看懂图里的整体走势;
    • 结合用户说明,判断是否存在异常峰值 / 断崖式下跌;
    • 输出一段结构清晰、可直接放进报告里的分析文本。

从第一性原理看,这其实就是:

4.2 多模态 Prompt 设计

我们可以继续沿用上面「先 OCR 再推理」的思路,把 Prompt 封装成一个函数,统一生成 HumanMessage

from langchain_core.messages import HumanMessage

def build_report_analysis_message(
    image_path: str,
    user_note: str,
) -> HumanMessage:
    """构造报表截图分析的消息(图片先经过 deepseek-ocr 识别)。"""
    system_text = (
        "你是一名电商运营分析师,擅长解读 GMV 等业务指标的趋势。\n"
        "给你一张报表截图和用户补充说明,请用中文输出:\n"
        "1)整体趋势概述;2)明显异常点;3)可能原因;4)后续建议。"
    )

    ocr_text = ocr_image_with_ollama(image_path)

    return HumanMessage(
        content=[
            {"type": "text", "text": system_text},
            {"type": "text", "text": f"用户补充说明:{user_note}"},
            {"type": "text", "text": f"截图 OCR 识别结果:\n{ocr_text}"},
        ]
    )

核心思路是:把所有「文字上下文」也装进 content_blocks,而不是混在 function 参数之外。这样:

4.3 从输出中提取「适合写报告」的文本

为了便于下游消费,我们可以规定:
「最终输出只保留 type == "text" 的块,按顺序拼起来」:

def compose_text_from_blocks(blocks):
    return "\n".join(
        block["text"]
        for block in blocks
        if block.get("type") == "text" and block.get("text")
    )

def analyze_report(image_path: str, user_note: str) -> str:
    message = build_report_analysis_message(image_path, user_note)

    response = model.invoke([message])
    return compose_text_from_blocks(response.content_blocks)

这段代码看起来非常朴素,但在工程实践里有几个好处:


五、实践建议与踩坑提示

最后,总结几个做多模态时容易踩的坑,以及在 LangChain v1 + content_blocks 模式下的推荐做法。

5.1 尽量早用标准结构,而不是 provider 原生 JSON

5.2 图片大小与数量控制

{
    "type": "image",
    "base64": "...",
    "mime_type": "image/png",
    "purpose": "main_chart",  # 自定义字段
}

5.3 与后续 middleware / 安全审计的衔接

提前用标准结构组织多模态输入输出,会极大简化后续工作:


六、小结:从「多模态 Demo」到「多模态基础设施」

这一篇我们做了三件事:

从架构视角看,更重要的是:

当你在系统一开始就采用标准 content_blocks 组织所有多模态数据时,
后面引入 middleware、日志、审计、安全策略,都会变得简单而自然。

在下一篇(第 14 篇)里,我们会把视角从「模型多模态」扩展到「工具多模态」:
如何让 MCP 工具返回的文本 + 图片,同样通过 content_blocks 统一解析,从而在前后端、模型与工具之间建立一条真正一致的多模态数据通路。

文档信息

京ICP备2021015985号-1