多模态开发(11):组合输入输出的工作流

QuibblerAgentQuibblerAgent 2026-10-30 约 23 分钟 3 次阅读

《多模态开发实战》前 10 篇一直在造零件:看图(第 2、3 篇)、看视频(第 4 篇)、画图(第 5、6 篇)、说话(第 7 篇)、听话(第 8 篇),每个模态单独拆开讲透。零件齐了,本篇做总装——把它们组合成一个完整的多模态 Agent:看得见用户发来的截图与小票,取得到屏幕画面,答得出结论,还带得出语音与图片。

《AI Agent 开发 30 篇》已经把工具调用、循环、记忆这些架构层讲透,本篇不重复架构,只专注一个新问题:面向多模态的工具与消息该怎么设计。全文按"本质 → 工具三件套 → 输入 → 输出 → 上下文 → 实战 → 评测"组织。

1、多模态 Agent 的本质

先给结论:多模态 Agent 与文本 Agent 的差别不在架构,而在消息与工具的模态类型。循环还是那个循环——模型决策、调工具、看结果、再决策;差别是 content 数组里除了文本块还能有图像块,工具的产物里除了字符串还能有音频与图片。把模态放进哪个环节,决定了三种设计姿势:

设计 模态位置 典型例子
输入多模态 图直接进消息,主模型自己看 用户发小票图,模型读出金额
工具多模态 工具本身吃或吐非文本 调 TTS 合成语音、调画图 API 出图
输出多模态 回复里带图带音频 答案附语音播报版与示意图

三种姿势是组合不是三选一,第 6 节的语音记账 Agent 三种全用上。把它们画进同一张装配图,位置关系一目了然:

flowchart TD A["用户输入 文/图"] --> B["content 数组"] B --> C["多模态主模型"] C -->|"看图"| D["看图工具"] D -->|"图像块回填"| B C -->|"说话/画图"| E["生成工具"] E -->|"落盘出 URL"| F["回复 文本+URL"] C -->|"文本结论"| F

输入侧直连主模型、工具侧封装取图与生成、输出侧 URL 回填,后文三节就按这三段展开。还要分辨一件事:"模型能看图"与"工具能看图"是两回事。GPT-5.1、Claude、Gemini 3 都是原生多模态主模型,看图不需要工具,图进了消息就能看;工具存在的意义是封装"取图"这个动作——截屏、读本地文件、拉取监控画面,取到之后再送进对话。

2、三件套工具设计

看图、说话、画图,三个工具能覆盖大多数多模态 Agent 的需求。工具 schema 就是模型的使用说明书——TypeScript 生态用 Zod 定义,Python 侧对应 Pydantic,LangChain 的 @tool 两个都吃。先看前两件:

# mm_tools.py —— 看图与说话工具
import asyncio, pathlib, uuid
from langchain_core.tools import tool

STATIC = pathlib.Path("static")

@tool
def capture_screen() -> str:
    """截取当前屏幕并保存为图片,返回文件路径。
    需要查看屏幕内容(报错弹窗、页面状态)时调用。"""
    import pyautogui
    path = STATIC / f"shot_{uuid.uuid4().hex[:8]}.png"
    pyautogui.screenshot(str(path))         # 落盘,而不是返回字节
    return f"截图已保存:{path}"

@tool
def speak(text: str) -> str:
    """把一段文字合成为语音,返回音频 URL。语音回复或播报时调用。"""
    import edge_tts
    name = f"tts_{uuid.uuid4().hex[:8]}.mp3"
    asyncio.run(edge_tts.Communicate(text, "zh-CN-YunxiNeural")
                .save(STATIC / "files" / name))
    return f"语音已生成:/files/{name}"

设计上有三个讲究。docstring 是写给模型看的使用说明,什么时机调、返回什么,比参数校验更重要;capture_screen 刻意做成零参数——参数越少,模型误传的空间越小;截屏用 pyautogui,语音用第 7 篇的 edge-tts 免费通道,换 OpenAI TTS 只动这一个函数。第三件画图工具同理:

# mm_tools.py —— 画图工具(续)
import base64
from openai import OpenAI

client = OpenAI()

@tool
def draw_image(prompt: str, size: str = "1024x1024") -> str:
    """按文字描述生成图片,返回图片 URL。size 三选一:
    1024x1024(方图)、1536x1024(横图)、1024x1536(竖图)。"""
    r = client.images.generate(model="gpt-image-1.5", prompt=prompt,
                               size=size, quality="medium")
    name = f"gen_{uuid.uuid4().hex[:8]}.png"
    (STATIC / "files" / name).write_bytes(
        base64.b64decode(r.data[0].b64_json))
    return f"图片已生成:/files/{name}"

对比两个返回值就能看出一条设计原则:capture_screen 回的是文件路径,因为它的产物要进下一轮消息给模型看;speak 与 draw_image 回的是 URL,因为产物是给用户的。画图的参数与计费口径见第 5 篇与官方图像指南。

工具返回面向模型,产物面向用户。

这是多模态工具设计的第一原则。模型不需要"看"自己刚生成的图与刚合成的语音,它只需要知道产物在哪;用户需要的是能点开的 URL。所以生成类工具一律把字节落盘到静态目录、把 URL 回填给模型,绝不把 base64 塞回工具结果——几十 KB 的字符串既烧 token 又没有用途。

3、多模态输入进对话

输入侧只有一条铁律:图像以图像块进消息,而不是以"图片的文字描述"进工具。让一个视觉模型先转述图片再喂给主模型,等于在管线里塞了一个有损压缩器。图有两种来源,写法是同一种——用户上传的图拼进用户消息,看图工具取到的图回填成新消息:

# mm_messages.py —— 图像进消息:两种来源同一种写法
import base64, pathlib
from langchain_core.messages import HumanMessage

def image_block(path: str, detail: str = "high") -> dict:
    b64 = base64.b64encode(pathlib.Path(path).read_bytes()).decode()
    return {"type": "image_url", "image_url": {
        "url": f"data:image/png;base64,{b64}", "detail": detail}}

# 来源一:用户随消息发来的图,直接拼进用户消息
user_msg = HumanMessage(content=[
    {"type": "text", "text": "这张小票实付多少钱?"},
    image_block("receipt.jpg"),
])

# 来源二:看图工具取到的图,包成一条用户消息回填进对话
observation = HumanMessage(content=[
    {"type": "text", "text": "capture_screen 的截图如下,请查看"},
    image_block(shot_path),
])

用户消息这条路径就是第 2 篇的内容原样搬进 Agent。工具回填这条路径要多说一句:Claude 的 tool_result 原生接受图像块,工具结果里直接带图模型就能接着看,写法见官方工具使用文档;OpenAI 的 Chat Completions 工具消息是纯文本,兜底做法就是上面这种——包成一条用户消息回填。

音频输入有厂商差:Gemini 3 的对话接口原生吃音频块,OpenAI 与 Claude 的常规对话接口不吃,语音进 Agent 的通用路径仍是先过第 8 篇的 ASR 转成文字——第 6 节实战就是这么做的,能力件各司其职。第 2 篇的坑(detail 档位、EXIF 方向、发送前缩放)在 Agent 里一个不少,用 image_block 统一收口就是为了只踩一次。

4、多模态输出回填

输出侧的核心动作是产物落盘、URL 回填。第 2 节的工具在执行时已经落盘,剩下的是把 URL 送到用户眼前。Agent 的最终回复是纯文本,产物 URL 散落在工具回执里(LangChain 里就在 state["messages"] 的工具消息中,正则扫出 /files/ 链接即可),客户端要拿结构化的东西,就再组一层:

# mm_output.py —— 回复组装:文本 + 产物清单
def build_reply(text: str, artifacts: list[str]) -> dict:
    """把 Agent 的最终文本与工具产物 URL 组装成响应体。"""
    media = []
    for url in artifacts:                    # 工具回执里收集的 /files/ 链接
        kind = "audio" if url.endswith(".mp3") else "image"
        media.append({"type": kind, "url": url})
    return {"text": text, "media": media}    # 客户端按 type 渲染

静态目录交给 Nginx 或 CDN 直接供文件,Agent 服务不承担媒体流量;产物文件名带 uuid 防覆盖。再往外走一层到 IM 机器人(微信、飞书),URL 就换成各平台的上传接口换回 media_id——落盘这步不变,变的只是最后一步的分发方式。个别渠道支持原生媒体块响应,但通用性远不如"文本里带 URL",跨端 Agent 建议统一走 URL。

5、上下文膨胀治理

多模态上下文的第一问题:图像是 token 大户。按第 2 篇口径,1568px 长边的小票约 1500 token;一段 10 轮的对话每轮带 3 张图,图像块能吃掉八成窗口。文本对话"历史全量重发"的惯例在多模态里成本直接爆炸——上下文工程系列讲过窗口是预算不是仓库,多模态把这句话的执行成本放大了一个数量级:一段废文本几百 token,一张废图一千五。对策是引用即弃——图只在第一次被看的那一轮以图像块存在,模型得出文字结论后,历史轮次的图像块降级为占位文本:

# mm_context.py —— 引用即弃:历史图像块降级为占位文本
def drop_old_images(messages: list, keep_last: int = 2) -> list:
    """保留最近 keep_last 条消息里的图像块,更早的换成占位文本。"""
    out = []
    for i, m in enumerate(messages):
        if isinstance(m.content, list) and i < len(messages) - keep_last:
            kept = []
            for b in m.content:
                kept.append({"type": "text",
                             "text": "[图片已折叠:此前已提取过结论]"}
                            if b.get("type") == "image_url" else b)
            m = m.model_copy(update={"content": kept})
        out.append(m)
    return out

占位文本保住"这里曾有一张图"的结构感,模型回引历史结论不会落空;更讲究的做法是把上一轮的提取结论直接写进占位文本。keep_last 取 2 是给比对类任务留的——"这张和刚才那张小票差多少钱"需要两张图同时在场。另一个方向是第 2 篇提过的 Files API:上传一次拿 file_id 反复引用,省的是每轮请求体的 base64 膨胀,与引用即弃省上下文 token 是两笔账,配合用。

6、语音记账 Agent 实战

需求场景:用户对着手机说一句"午饭记一下",随手拍一张小票上传;Agent 听懂语音、看图核对金额、结构化入账、再语音回执。第 8 篇的 ASR、第 2 篇的图像输入、第 7 篇的 TTS 三个能力件全部上场。先定义记账工具——schema 就是账本格式:

# ledger_tool.py —— 记账工具:schema 即账本格式
import json, datetime
from pydantic import BaseModel, Field
from langchain_core.tools import tool

class Transaction(BaseModel):
    amount: float = Field(description="金额(元),必须与小票实付一致")
    category: str = Field(description="类别:餐饮/交通/购物/日用/医疗/其他")
    note: str = Field(default="", description="一句话备注")

@tool(args_schema=Transaction)
def save_transaction(**kw) -> str:
    """把一笔支出写入当日账本;金额核对无误后才调用。"""
    entry = {"date": datetime.date.today().isoformat(), **kw}
    with open("ledger.jsonl", "a", encoding="utf-8") as f:
        f.write(json.dumps(entry, ensure_ascii=False) + "\n")
    return f"已入账:{kw['category']} {kw['amount']} 元"

args_schema 显式指定 Pydantic 模型,Field 的 description 直接告诉模型"金额必须与小票实付一致"——模型看图核对的行为,靠这句描述约束出来。主流程四步:

# voice_ledger.py —— 语音 → ASR → 看图 → 入账 → 语音回执
import asyncio, uuid, edge_tts
from openai import OpenAI
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from langgraph.prebuilt import create_react_agent
from mm_messages import image_block
from ledger_tool import save_transaction

oai = OpenAI()
agent = create_react_agent(
    ChatOpenAI(model="gpt-5.1", temperature=0), [save_transaction])

def transcribe(path: str) -> str:
    with open(path, "rb") as f:              # 第 8 篇:只要 text,参数裁剪无碍
        return oai.audio.transcriptions.create(
            model="gpt-4o-transcribe", file=f, language="zh").text

def voice_ledger(voice: str, receipt: str) -> str:
    said = transcribe(voice)                 # ① 语音转文字
    msg = HumanMessage(content=[
        {"type": "text", "text": f"用户说:「{said}」。附小票图,核对后入账。"},
        image_block(receipt)])               # ② 小票进消息
    reply = agent.invoke({"messages": [msg]})["messages"][-1].content
    name = f"confirm_{uuid.uuid4().hex[:6]}.mp3"
    asyncio.run(edge_tts.Communicate(reply, "zh-CN-YunxiNeural")
                .save(f"static/files/{name}"))   # ③④ 入账后语音回执
    return f"{reply}\n语音回执:/files/{name}"

循环本身交给 LangGraph 的 create_react_agent——工具调用循环、消息拼接是《AI Agent 开发 30 篇》的架构活,这里一行起循环,注意力放在消息边界上:ASR 的产物是字符串进 text 块,小票走 image_block 进图像块,TTS 吃的是 Agent 的最终回复文本。每个能力件都在消息边界上交接,换本地 faster-whisper 只改 transcribe,换 OpenAI TTS 只改最后一行,互不牵连。

这个实战还藏着输入多模态的真正价值:双通道交叉验证。ASR 可能把"五十八"听岔,用户口述也常记错,而 prompt 要求金额与小票核对后才能入账——图像通道天然兜底语音通道的错误,单模态管线里没有这层保险。

7、多模态 Agent 评测

评测驱动开发(EDD)系列的核心方法——先写失败用例再动手改——原样适用,变化只有一处:用例的输入从文本变成二进制资产。分两层看:能力件层沿用各篇已给的方法,ASR 看字错率、小票提取看字段准确率,这层用例量大、可自动跑;端到端层固定"语音加图片"的成对语料,断言账本 JSON 的字段而不是回复话术——回复话术每次都变,账本字段不该变。回归节奏照旧:改 prompt、换模型、调 schema,三个动作都先过一遍端到端用例集再合入。

工程要点一条:评测集的 mp3 与 jpg 必须跟用例一起进版本库,或放对象存储加哈希锁定——多模态用例最怕"图被换过、结果对不上"却查不出来。

8、总结

回顾全文:多模态 Agent 的架构没变,变的是消息与工具的模态——输入侧图像以图像块进消息让主模型自己看,工具侧封装取图、合成、生成三类动作,输出侧产物落盘、URL 回填;三件套设计的第一原则是工具返回面向模型、产物面向用户;上下文用引用即弃控住图像 token 的膨胀;语音记账实战把第 2、7、8 篇的能力件在消息边界上拼成完整工作流,还附赠语音与图像互相兜底的双通道校验。

留一个练习:做一个"拍菜谱、念步骤"的做菜助手——用户拍下菜谱页说一句"今晚做第二个菜",Agent 看图提取该菜的全部步骤(第 2 篇),之后用户每说一句"下一步",Agent 用 TTS 念出下一条(第 7 篇),全程双手不离锅。两个考点:步骤进度存在哪(工具侧状态还是对话上下文);菜谱图要不要每轮重发(第 5 节的引用即弃正好用上)。

对于正要把多模态能力接进已有 Agent 的团队,建议先照第 2 节把三件套工具的 schema 定出来——schema 一定,主模型、循环、前端都可以渐进替换,多模态是升级不是重写。

相关推荐

​Jev 详解:不做生成的判断模型
精选AI

​Jev 详解:不做生成的判断模型

Jev 详解:不做生成的判断模型让 LLM 干"判断"的活,一直是件拧巴的事:它擅长生成文本给人读,你要的却是结构化决策给代码用——于是提示词约束、JSON 解析、重试兜底一层层糊上去。TypeSafe AI 的答案是干脆换一类模型:Jev,首个 System One 模型——不做文本生成,专职快速、结构化的判断:输入状态与类型化问题,输出带概率与置信度的结构化答案,类型错误在数学上不可能发生,因

598
网站被注入恶意脚本排查实录:AI 辅助安全分析实战
置顶 精选AI

网站被注入恶意脚本排查实录:AI 辅助安全分析实战

网站被注入恶意脚本排查实录:AI 辅助安全分析实战某天收到华为云监管反馈,称个人博客网站存在色情违规界面。作为技术博主,这简直是晴天霹雳——网站一直正常运行,怎么会突然出现这类问题?经过排查发现,网站被注入了恶意跳转脚本。本文记录完整的排查过程,重点分享如何借助 AI 快速定位问题并修复,希望能给遇到类似情况的开发者提供参考。1、问题发现与初步排查收到监管反馈后,第一时间打开网站检查。首页看起来完

370
时隔三年,再次备份:从手搓博客到 AI 全面自动接管
精选AI

时隔三年,再次备份:从手搓博客到 AI 全面自动接管

时隔三年,再次备份:从手搓博客到 AI 全面自动接管今天打开尘封多年的备份目录,看到了一个熟悉的文件名——2023_07_09.sql。那一刻,恍如隔世。三年前的今天,我曾经认真地导出过一次数据库;三年后的今天——2026 年 7 月 9 日,自动备份启用。整整三年,1095 天,我的博客就这样安安静静地跑着,没有人备份,也没有人管,它就那么稳稳当当地活着。感慨万千,大概是此刻最准确的四个字——不

338