大模型多模态、Harness 与 MCP:一个 Agent 开发者的技术笔记
最近一直在折腾 Agent 开发,从最早只会调大模型 API 返回 JSON,到现在把多模态输入、Harness 控制逻辑和 MCP 工具协议全部串进一条流水线,踩的坑比写出来的代码还多。这篇文章就是我这段实践的整理,重点聊三个容易被忽略但又决定 Agent 能不能落地的环节:多模态信号怎么进模型、Harness 怎么设计才不会被业务逻辑压垮、MCP 怎么接才能让工具调用真的可控。如果你是刚入门的开发者,这篇文章可以帮你少走很多弯路;如果你已经在做 Agent 框架选型,里面的一些坑和取舍可能对你有参考价值。
先说结论:多模态模型负责“看懂”,Harness 负责“决策收敛”,MCP 负责“执行协作”。这三者分开看都不算新东西,但把它们放在同一个系统里协同工作时,才会暴露出真正的问题。下面我按实际开发的顺序,把每个部分的思路、代码骨架和踩过的坑展开聊。
1. 整体设计:Agent 不是“调模型”,而是“编排系统”
很多人第一次做 Agent,脑子里只有“把用户问题丢给大模型,拿到结果再返回”这一条直线。真正做下来你会发现,一个能稳定处理复杂任务的 Agent,本质上是一个小型的分布式系统,只不过核心决策单元换成了大模型。整个系统的架构可以拆成三层:感知层(多模态输入处理)、决策层(模型推理 + Harness 策略控制)、行动层(工具调用与外部系统对接,基于 MCP 协议)。
1.1 三个核心关键词的定位
先说多模态。我这边实际用到的场景包括截图理解、摄像头画面分析、语音指令识别、甚至 PDF 里的表格转结构化数据。多模态模型的价值在于它能把非结构化信息直接变成决策依据,省去以前“先用 OCR 识别、再写正则清洗、再拼接上下文”这一整套繁琐管线。
再说 Harness。这个词直译是“控制装置”,在 Agent 开发里我更喜欢把它理解成“决策安全带”。它是一层夹在用户请求和模型调用之间的策略代码,负责约束模型的行为范围、决定调用哪些工具、处理模型返回的中间结果。没有这层约束,模型输出就像脱缰的野马,什么都敢答,什么都敢做。
最后是 MCP(Model Context Protocol,模型上下文协议)。它解决的是“工具接入标准化”的问题。以前每接一个工具就要写一套适配代码,现在通过 MCP 协议定义工具接口,模型可以动态发现可用工具并按其规范调用。MCP 之于 Agent,相当于 USB-C 之于外设——通过一个统一接口,设备能即插即用。
1.2 为什么不用“一条龙”式管线
我最早的原型版本很简单:用户输入图片和文字,拼接成多模态请求,模型返回 JSON,我再按 JSON 字段去执行操作。这个方案在演示时没问题,但一上真实任务就崩。
崩的原因有三:一是模型经常返回格式错误的 JSON,或者字段名“灵活发挥”,导致解析层崩溃;二是多模态输入中的无关信息太多,模型容易被带偏,比如用户同时上传了一张产品图和一张白板笔记,我原本只需要理解产品图,模型却把白板上的“下周上线”也当成了需求的一部分;三是工具调用完全没有准入控制,模型在连续对话中可能会反复调用某个外部接口,造成重复扣费和资源浪费。
把 Harness 加进去之后,这些问题被拆散到不同层级解决:JSON 解析失败由重试机制兜底,无关输入由“意图槽”机制过滤,重复调用由工具调用黑名单和限额控制解决。这才是把 Agent 当成系统来开发的正确姿势。
2. 多模态输入:别只用“图文拼接”,要理顺输入通道
如果你只是想让模型“看图说话”,直接把 image_url 塞进请求就行。但在 Agent 场景下,多模态输入不仅是“看图”,还涉及截图、实时画面、语音、文件解析、多轮对话中的图像状态维护。这里面的复杂度比想象中大得多。
2.1 多模态输入的数据流设计
我开发的多模态 Agent 支持三类输入:图片(文件上传)、截图(来自屏幕采集)、视频帧序列(来自直播流或本地视频)。每条输入都走同样的数据管线:
- 输入预处理:统一压缩图片尺寸、转换格式、提取 EXIF 信息,避免部分图片因格式问题直接导致请求失败。
- 内容分类:先用一个小模型或规则引擎判断输入类型。这一步的“判定结果”会作为元信息和原始内容一起传给后续流程,比如识别到“这是一张表格截图”,就标记为结构化提取任务。
- 构建消息序列:把图像按时间顺序插入 messages,并明确标注“current frame”“previous frame”这样的时序标签。
这里有一个容易踩的坑:绝大多数多模态模型对图像数量的容忍度有限,一般超过 10 张图就会严重影响响应速度和输出质量。如果你在对话轮次中反复追加图片,最终上下文里积压的图片信息会让模型“记忆混乱”,甚至把之前的图像内容当作当前输入的一部分。
我的解决方案是维护一个“视觉 Buffer”,只保留最近 N 帧画面(我这边取 3 帧),更早的图像直接用一段描述性文字替身。这样既保留了场景变化脉络,又不会让视觉上下文失控。
2.2 跨模态对齐:图文不一致时的处理策略
多模态最棘手的问题不是“模型看不懂”,而是“模型理解错”。典型例子:用户上传了一张设计稿截图,同时说“帮我实现这个页面,但我只要移动端版本”。截图里的设计稿明显是桌面端布局,这种“图文不一致”的信息,模型往往会默认按图执行,把桌面端布局全套搬迁到移动端。
我处理这类问题的方式是引入“归因校验层”:在将多模态数据送入主模型之前,用一条独立指令让模型先做“信息一致性检查”。如果图文之间存在冲突,要求模型必须把冲突点单独列出,并说明自己的处理倾向,而不是直接默默忽略。
实际操作中,我还在系统里增加了一个“意图槽”机制。对每轮用户输入,我定义了几个强制填写的槽位:任务类型(识别、生成、操作、分析)、目标对象(哪个文件/哪个区域)、输出格式(JSON、文本、表格)、约束条件(颜色、尺寸、样式)。Harness 会从用户的图文内容中抽出这些槽位,并把“填充完成度”作为是否启动模型推理的前置条件。
第一次做这一步的人容易把槽位检查做得太严格,导致用户稍微说得含糊一点系统就卡住。我调参后的经验是:意图槽分为“必填槽”和“可选槽”,缺必填槽时主动向用户追问,缺可选槽时用默认值替代,并把这个替代行为显著告知模型。
3. Harness 设计:约束模型,但不勒死模型
Harness 这层是 Agent 之所以能稳定运行的根基。很多开源项目里的 Agent 看起来很强,只是因为 demo 场景固定;真正换到生产环境,模型输出不可控的问题立刻暴露。Harness 解决的就是“把大模型的自由度收敛到一个安全边界内”。
3.1 Harness 的三种常见模式
我实际写过的 Harness 代码经历了三次迭代,可以归纳成三种模式:
第一代:路由模式。根据用户意图直接分发到不同提示词模板。好处是逻辑简单,坏处是模型一旦识别错意图,整个链路就歪了;而且多模态输入量大时,每轮都做意图识别会增加一次模型调用成本。
第二代:规划器模式。先让模型输出一个“行动计划”(比如先调用工具 A 获取数据,再调用工具 B 生成报告),Harness 按计划逐步执行并检查每一步的中间结果。这种模式的优点是可控性极强,缺点是模型经常规划出不可执行的步骤,一旦某一步卡住,需要动态修改计划。
第三代:反射器模式。模型每执行一步,Harness 就分析当前的中间输出和世界状态,决定是继续原有路径、回退重试,还是切换策略。这种模式最灵活,但也最容易写出“难以收敛”的死循环。
我目前的生产版本结合了三种模式:默认走规划器模式,遇到规划错误或工具执行异常时切换到反射器模式;对于高频固定任务(比如截图识别、日志分析),直接走路由模式,绕开模型规划的开销和不确定性。
3.2 工具调用的“准入与配额”控制
没有配额控制的 Harness 等于没有安全带。我见过不少事故:开发者只给模型接了数据库查询工具,没做任何限制,结果模型在自我反思时反复调用同一查询接口,把数据库连接池打满。我的做法如下:
第一层是工具可见性过滤。不同场景暴露给模型的工具集合不同。比如在做“只看不动”的分析任务时,所有写操作工具都从工具列表里隐去,模型根本不知道有这些工具,自然就不会调用。
第二层是调用配额。每个工具按 token 消耗和外部系统实际负载设定单次会话的最大调用次数。比如文件写入工具最多调用 10 次,网络请求工具最多调用 5 次。配额耗尽后 Harness 会强制切换到人工确认模式。
第三层是执行前置检查。对于“发送邮件”“删除文件”这类高危操作,无论模型怎么决定,都必须经过二次确认接口。这个确认接口可以设计成“仅允许人工审批”,也可以设计成“默认拒绝,仅白名单场景放行”。
注意:工具调用配额不要设成静态值,最好在运行时按任务的复杂度和上下文长度动态调整。简单任务配额设大了浪费系统资源,复杂任务配额设小了任务根本完不成。
3.3 Harness 与状态管理
Harness 还要负责任务级状态管理。大模型本身是无状态的,每次调用之间不保留任何记忆。如果你用对话历史来模拟状态,很快会遇到两个问题:上下文长度爆炸、模型被旧信息干扰。
我的方案是在 Harness 内部维护一个“最小状态集”,包括:当前目标、已执行的步骤列表、待办事项、约束条件、工具调用历史。每次循环只把这个状态集序列化成文本,与历史对话分开、拼接到系统提示词后面。
这样做的好处是状态信息精简且无冗余,模型不会被无关历史带偏。缺点是状态集的更新逻辑需要 Harness 自己写清楚——每执行完一步后手动修正状态。如果你把状态管理也丢给模型去做,它可能会“遗忘”之前填写的约束条件。这事我踩过坑,最后用“状态覆盖写”机制才解决:每当用户提出新要求,Harness 先更新状态集中对应字段,再把这个更新动作显式告诉模型。
4. MCP 服务端设计:让工具调用有上下文、有约束、可复用
MCP 的核心价值是标准化工具调用协议,但光有标准还不够,服务端的设计质量直接决定了工具协作的体验。这里分享我在 MCP 服务端设计上的几条核心经验。
4.1 MCP 与普通 function calling 的本质区别
很多开发者问:MCP 不就是把 function calling 的参数列表抽出来吗?还真不是。MCP 真正解决的问题是——工具调用与模型推理的“上下文连续性”。
在普通 function calling 里,模型拿到的是一个静态工具列表和参数模板,调用完之后结果只是作为一个新消息塞回来。工具之间彼此孤立,模型也记不住上一次调用的状态。MCP 引入了“资源”和“上下文”的概念,工具调用的输入输出可以和系统状态、用户会话、企业数据源打通。工具不再是函数集合,而是有生命周期的服务。
一个直观的对比:普通方式下你要实现“读取文件 → 转换格式 → 存入数据库”这条链路,得写三段胶水代码,并自己维护文件的临时状态;MCP 方式下,三个环节你各写一个工具,然后让模型通过协议按顺序调用,中间状态的传递由协议上下文自动承载。
哪怕你只是做小项目,我建议也一开始就用 MCP 来定义工具,哪怕服务端暂时不部署独立进程。因为这样工具定义就和业务逻辑解耦了,后续横向扩展工具数量时不需要改 Agent 主流程代码。
4.2 MCP 服务端设计的四个关键点
第一,连接生命周期管理。MCP 客户端会反复建立和断开连接,服务端一定要做好会话的幂等处理。一个常见的坑:客户端重连后重复注册同名的工具,服务端直接报错。我的做法是工具注册时按“服务名 + 工具名”作为唯一键,重复注册时走更新逻辑而不是报错。
第二,工具输入输出 Schema 要严格。MCP 协议里工具的参数必须用 JSON Schema 描述,这一块千万别偷懒。我测试下来,写清楚 required 字段和 format 约束可以规避大约 30% 的工具调用失败。原因是模型在自由生成参数时,如果没有格式约束,经常生成类型不匹配的值,比如把字符串传给整数参数。
第三,工具扇出(fan-out)要有限度。MCP 允许服务端暴露大量工具,但同一时刻暴露给模型的数量不宜过多。模型处理的工具选择空间太大时,会频繁出现“选错工具”的低级错误。我这边会把工具按领域分组,通过配置项控制每次会话加载的工具组。
第四,错误信息要结构化。工具执行失败时,不要只返回“调用失败”这样的字符串。我设计的错误响应包含错误码(比如 TOOL_TIMEOUT、RATE_LIMITED、SCHEMA_INVALID)、重试建议、相关上下文摘要。模型看到结构化错误信息后,能自动调整策略重试,而不是把错误转述给用户就完事了。
4.3 MCP 服务端代码骨架与核心实现
下面给一个最精简的 MCP 服务端骨架,使用 Python 和 mcp 官方 SDK(核心逻辑可平滑迁移到任何语言)。这段代码实现了两个工具:读取文件内容、解析图片尺寸。
import json from typing import Any from mcp.server import Server from mcp.server.stdio import stdio_server app = Server("asset_tools") @app.list_tools() async def list_tools() -> list[dict[str, Any]]: return [ { "name": "read_text_file", "description": "读取本地文本文件内容,返回纯文本。", "inputSchema": { "type": "object", "properties": { "path": {"type": "string", "description": "文件绝对路径"}, "encoding": {"type": "string", "default": "utf-8"} }, "required": ["path"] } }, { "name": "get_image_size", "description": "获取图片宽高像素信息,不读取像素内容。", "inputSchema": { "type": "object", "properties": { "path": {"type": "string"} }, "required": ["path"] } } ] @app.call_tool() async def call_tool(name: str, arguments: dict[str, Any]) -> list[dict[str, Any]]: if name == "read_text_file": path = arguments["path"] encoding = arguments.get("encoding", "utf-8") try: with open(path, "r", encoding=encoding) as f: content = f.read() return [{"type": "text", "text": content[:5000]}] except FileNotFoundError: return [{ "type": "text", "text": json.dumps({ "error_code": "FILE_NOT_FOUND", "detail": f"{path} 不存在", "suggestion": "检查路径或切换为路径探测工具" }) }] elif name == "get_image_size": path = arguments["path"] try: from PIL import Image with Image.open(path) as img: w, h = img.size return [{"type": "text", "text": f"{w}x{h}"}] except Exception as e: return [{ "type": "text", "text": json.dumps({ "error_code": "IMAGE_PARSE_ERROR", "detail": str(e), "suggestion": "确认文件为常见图片格式且未被损坏" }) }] else: return [{"type": "text", "text": f"未知工具: {name}"}]这个骨架虽然简单,但已经体现了我反复强调的几个点:一是 list_tools 返回的 Schema 是结构化且带描述的,方便模型理解;二是 call_tool 里每个分支都做了异常兜底,返回的是结构化信息而不是裸字符串错误;三是工具内部做了输出长度截断(5000 字符),防止模型上下文瞬间被撑爆。
实际项目中,MCP 服务端通常部署为独立进程,客户端通过 stdio 或 SSE 连接。多智能体场景下,建议一个服务端只暴露一个领域内的工具集,不要把所有工具塞进同一个进程。
5. 稳定性工程:Agent 和外部世界交互时最容易翻车的地方
多模态 Agent 一旦接入外部系统,稳定性问题就变得极其突出。这一节讲的都是我在生产环境下真正翻过车的地方,每一条都对应一个真实的 bug。
5.1 请求超时管理
模型推理和工具执行都可能远超预设时间。我最初设计时,给模型调用设置 120 秒超时,给工具执行设置 30 秒超时,结果并不理想。因为工具超时后,模型拿到“超时错误”,常常会重新发起同样的调用,反而加剧了外部系统压力。
现在的做法是分级超时 + 超时后策略分离:
- 一般工具调用:15 秒超时,超时后重试一次,仍失败则跳过;
- 外部 HTTP 请求:5 秒连接超时 + 20 秒读取超时,超时后不重试,记录失败原因;
- 模型推理:根据上下文长度动态设置 60~180 秒,超时后切换轻量模型处理。
5.2 请求幂等与消息去重
Agent 连续执行时,模型可能在自我反思中重复调用同一个工具。比如一轮对话里用户说“把刚才那份报告再总结一次”,模型可能会先调用“获取报告”,再调用“总结”,而在总结失败后重新调用“获取报告”,产生重复读取。
我的方案是给每个工具调用生成一个“幂等键”,以输入参数的哈希值作为标识。短时间窗内(比如 5 分钟)如果收到相同幂等键的请求,服务端直接返回上一次的结果缓存。这个机制不仅避免了系统重复劳动,也减少了 token 消耗,因为模型不需要重复拿同样的数据再读一遍。
5.3 上下文预算
多模态模型的上下文既包含文本也包含图像 token,图像 token 的消耗远高于文本。模型单次请求里图片太多,不仅容易触发上下文超限,还会让响应变慢。我做了三层控制:
- 第一层:控制单轮输入图片数不超过 4 张;
- 第二层:控制多轮累计图片数不超过 8 张,超出后压缩为“图像摘要文本”;
- 第三层:每次对话结束后,计算已用的上下文 token 数,当超过模型最大上下文的 70% 时,强制触发“记忆压缩”流程。
5.4 可观测性:每个决策都要留痕
Agent 系统最让人头疼的问题就是“黑盒”。模型为什么选了这条路?哪一步出了问题?外部服务响应了什么?没有日志,排查故障就像大海捞针。
我实现了三层日志:第一层是系统级日志,记录每个模块的调用耗时和结果;第二层是决策级日志,记录 Harness 的每次路由判断、工具选择、策略切换;第三层是模型级日志,记录发送给模型的完整提示词和模型返回的原始结果。
排查故障时有一个技巧:先看决策级日志找到“模型做了错误判断”的节点,再回看模型级日志,把那次请求的提示词和返回结果对比,通常能迅速定位是提示词引导问题还是模型本身能力问题。
6. 工具选型与落地建议
如果你现在准备从零搭建一个多模态 Agent,工具链怎么选?我根据实际踩坑经验,给出几个组合建议。
6.1 模型选型
多模态能力差异很大,不是每个模型都适合做 Agent 的“大脑”。我的选择标准有三个:
- 长上下文能力:至少要能稳定处理 32K token 的文本 + 几张图的混合输入;
- 指令遵循能力:测试方法是给模型一个包含 5 个操作步骤的复杂指令,看它能不能一步步完整执行而不遗漏;
- 结构化输出能力:看模型能不能稳定输出合法 JSON,而不是偶尔把注释也带进去。
6.2 Harness 框架选择
市面上的 Harness 框架各有侧重。有的注重规划能力,有的注重工具编排,有的注重可观测性。我的建议是不要盲目引入重框架,先自己写一个 200 行的最小 Harness 跑通主流程,理解其核心机制后,再决定是否引入框架。因为引入框架会带来更新适配成本,而多模态 + MCP 的场景变化很快,精简的自研代码反而更可控。
6.3 MCP SDK 选择
优先选择官方 SDK,因为协议格式还在快速迭代,第三方 SDK 容易滞后。用官方 SDK 的好处是接口稳定,后续协议版本升级时迁移成本低。我这里用的 Python SDK 示例已经可以跑通工具发现和调用的全流程。
6.4 落地路径建议
最后给一个三阶段落地的路线参考:
第一阶段:技术验证。用现成多模态模型 + 手写 Harness + 两个 MCP 工具,跑通一个端到端场景(比如“上传合同截图,提取关键条款,写入数据库”)。这个阶段的目的是验证模型能力边界和工具调度逻辑。
第二阶段:稳定性加固。引入幂等、超时、上下文预算、结构化日志,把第一阶段暴露的问题逐项修复,目标是让系统在没有人工干预的情况下连续稳定运行 100 个任务。
第三阶段:横向扩展。按领域拆分 MCP 服务端,把工具从 2 个扩展到 20 个以上,引入更复杂的 Harness 策略(反射器模式),并按需引入多模型协同。
7. 典型问题排查:整理成速查表
最后把我遇到的高频问题整理成表格,方便你排查时直接对照。
| 问题现象 | 可能原因 | 排查思路与解法 |
|---|---|---|
| 模型拒绝回答图片相关问题 | 图片格式不支持或已损坏 | 先用 PIL 打开图片,确认格式和尺寸,重传图片或转码为 JPEG/PNG |
| 模型返回的 JSON 字段名漂移 | 提示词对输出格式约束不足 | 在系统提示词中给出强约束模板,并在解析层做字段模糊匹配 |
| 工具调用反复超时 | 外部服务响应慢,或工具本身存在阻塞调用 | 为工具执行加超时和线程池隔离,避免阻塞事件循环 |
| 模型在连续对话中重复调用同一工具 | 缺少幂等机制 | 为工具调用加缓存键,相同输入在时间窗内直接返回缓存 |
| 多模态上下文过大 | 图片 token 挤占上下文预算 | 限制图片数量,超限后用图像摘要代替原始图 |
| MCP 工具注册失败 | 工具名冲突或 Schema 不合法 | 检查唯一键、名称是否包含非法字符,用官方 Schema 校验工具验证 schema |
| Harness 进入死循环 | 模型一直选择相同失败路径 | 设置最大迭代次数(比如 8 次),超限后强制切换策略或结束任务 |
| 工具执行成功但返回结果为空 | 外部系统返回了空数据 | 记录请求参数和外部系统原始响应,确认是数据源为空还是解析丢弃 |
这套排查方法的核心逻辑是:先确认“模型输出是否正确”,再确认“工具调用是否正确”,两者分开排查,不要混在一起看。模型输出错就调提示词和 Harness 策略,工具调用错就查 MCP 服务和外部系统。
我自己的体会是:Agent 开发真正难的不是训练模型,而是设计一套能容忍模型“偶尔犯蠢”的系统。多模态负责拓宽信息入口,Harness 负责控制决策质量,MCP 负责让工具调用成为标准动作——三者的配合,才是 Agent 从 demo 走向可用产品的关键。每次改代码时,我都会先翻一遍日志,看最近失败样本的共性,再回头调 Harness 的策略参数,而不是盲目改提示词。这个方法不算高深,但很管用,推荐你也这样试一次。