
做 Agent 开发这两年如果说有一个词让我从“调模型接口的小朋友”慢慢变成“做系统的人”那就是把多模态、Harness 和 MCP 这三件事真正串起来的那一天。这三个词看起来各管一摊——多模态是模型的输入输出能力Harness 是调度与控制层的设计MCP 是工具接入的协议标准——但实际做项目时你会发现它们就像一辆车的引擎、方向盘和加油站少任何一个车都跑不远。我在团队里做过不少 Agent 方向的模拟项目从最开始的“拿大模型聊天”到后来的“让模型看懂单据、自己调接口、把活干完”中间的瓶颈从来不是模型不够聪明而是接入方式太随意。今天这篇笔记就是想把我在这三个方向上的理解、踩过的坑、还有现在觉得最顺手的做法一次性说清楚。适合正在做 Agent 开发、想引入多模态能力、或者被工具接入搞得头大的朋友。读完你应该能明白多模态处理的是什么信息Harness 控制的是什么逻辑MCP 标准化的又是什么连接方式——以及它们为什么必须在一起。1. 多模态让 Agent 真正“看见”和“听见”1.1 多模态在 Agent 里的真实形态很多人一提多模态第一反应就是“能传一张图给模型让它描述一下”。这没错但它只是最浅的一层。在 Agent 场景里多模态意味着三类信息流要能进到模型里并被结构化利用图像信息、音频信息、还有带格式的文档信息。图像信息是 Agent 最容易用上的。比如让 Agent 看一张产品截图判断图里有没有违规文字让它读一张票据提取金额和发票号让它看一张 UI 设计图生成对应的页面结构描述。这些任务如果让纯文本模型做只能靠 OCR 工具把文字抽出来再丢进去但版面结构、颜色、位置关系、图标含义这些信息就全丢了。有了多模态模型整张图可以作为输入直接进入模型模型能够结合视觉特征和语义理解给出更完整的判断。音频信息在 Agent 里通常是配合语音场景用的。比如语音助手接通用户电话需要先做语音转文字再把文本交给大模型理解但有些场景直接让多模态模型听音频特征更高效——比如判断通话背景是否嘈杂、识别说话人的情绪语调。这些能力在过去要专门训练音频模型现在一些多模态模型原生支持音频输入等于把这类需求从“定制开发”降级成了“调接口”。带格式的文档信息是很多人忽略的一块。PDF、Word、Excel 这类文件本质上是“有结构的视觉内容”而不是纯文字。早期的做法是转成纯文本再处理但表格线、页眉页脚、合并单元格、图表位置全都会乱。多模态模型可以直接接受文档页面的渲染结果把一页 PDF 当成一张图来理解版面信息完整保留提取关键字段的准确率往往比纯文本方案高不少。这三种形态对应的是三种不同的处理链路。图片路径通常是“加载 → 缩放/编码 → 送入模型”音频路径是“切段 → 转码 → 送入模型”文档路径则是“渲染成图像 → 切片 → 按页送入模型”。这里头最常被忽略的是文档渲染这一步很多人以为直接把 PDF 传给模型就行实际上模型接口根本不接受二进制文件你需要先把每一页渲染成 PNG 或 JPEG再按图像的方式处理。提示如果只做文字提取传统 OCR 可能更快更便宜但如果要做“理解”而不是“识别”比如判断表格中哪些字段重要、有没有隐藏关联关系那就得多模态模型上。1.2 多模态输出的价值不只是文字回复多模态不止是“输入”输出同样重要。Agent 在完成一个任务后很多时候需要交付的不只是一段文字而是一张图、一份带格式的报告、或者一段语音说明。我在一个模拟项目中做过这样一个需求用户上传一张户型图Agent 需要给出布局优化建议并输出一张改造后的平面示意图。这里模型需要先“看”懂原始户型图理解墙体、门窗、管道位置然后基于空间规则生成新的布局描述最终交给一个绘图模块渲染成图。这个链路里最难的并不是最后的绘图而是模型能不能把视觉信息转化为结构化的空间概念——这一步没有多模态理解能力根本做不到。语音输出也有类似价值。同样的告警信息推送一段文字给运维人员和播报一段语音给现场操作员体验差距是很大的。多模态生成模型可以把 Agent 的决策结果直接变成自然语音省掉额外的 TTS 环节。所以多模态在 Agent 里的定位应该是把世界的信息形态变得更加可处理。输入时它让模型不再局限于文本输出时它让 Agent 的结果不再局限于文本。这个能力对齐的是场景需求不是模型参数的炫技。1.3 投入产出比最关键的整备工作多模态真正落地时做得最多的不是调模型参数而是数据预处理。我总结下来有三件事是必须提前做的。第一件事是图片的规范化。模型对输入图片有分辨率上限通常会做缩放或裁剪。如果原图是高清扫描件直接压缩会导致小字识别不到如果原图是长截图直接缩放会把关键内容压没了。我的做法是先判断图片长宽比超过模型支持的范围就切片处理每一片保持足够的像素密度再让模型分别理解最后在 Harness 层把多片理解结果合并。这个策略比“整图粗暴压缩”的成功率高很多。第二件事是格式的提前转换。音频需要转成模型支持的编码格式和采样率文档需要按页渲染成图片。这些转换如果放在 Agent 运行时的主链路里会显著拉长响应时间。我习惯把它放在一个独立的预处理模块里打完标、转完格式再喂给模型甚至可以把转换结果缓存起来提高重复请求的处理速度。第三件事是输入截断策略。多模态模型的输入 token 计算方式和文本不一样一张高分辨率图能占掉几千 token。如果你同时传五张图上下文马上变紧张。所以我通常在 Harness 里做一道视觉信息压缩能裁剪就裁剪、能缩小就缩小、能只传局部就绝不传全图。这套做法在实际中的收益非常直接——同样一个任务压缩图片后模型输出质量不降成本却能省不少。2. HarnessAgent 的驾驶舱与方向盘2.1 为什么需要 Harness而不是写几段 Prompt多模态能力再强如果调用方式还是“喂一段 prompt等一个 JSON”那 Agent 就没法应对真实场景。真实场景的麻烦点在于状态一个任务往往要经历识别、判断、调用工具、再判断、再调用工具的一系列循环中间任何一步都可能失败或需要回退。如果你只是把多步请求串在一起很快就发现对话历史越攒越长、模型开始忘事、工具调用结果对不上。Harness 就是为解决这些问题而出现的。它不是一个模型也不是一套 Prompt而是围绕模型外围搭建的一套控制设施。你可以把它理解为 Agent 的驾驶舱模型是引擎Harness 是方向盘、仪表盘和刹车系统。它负责决定每一步怎么走、需要哪些上下文、该调哪个工具、遇到异常怎么处理。我早期做 Agent 时所有逻辑都写在调用模型的那个 Python 文件里看起来很快实际上后来改一个需求就要翻半天代码。引入 Harness 之后变化很明显模型调用变成了 Harness 里的一个动作而不是整个程序的核心状态管理、工具调度、上下文组装都有明确位置逻辑一下子清晰了。2.2 Harness 的核心模块上下文管理、工具路由、状态机、护栏真正好用的 Harness我认为至少要包含四个模块。第一个是上下文管理。这个模块负责维护对话历史和中间结果决定哪些信息应该留在模型可见范围里、哪些应该收起来。没有这个模块的 Agent 就像没有工作台的工人所有零件堆在桌面上越干活越乱。我在 Harness 里会为每个任务维护一个“工作区”里面放任务目标、已收集的事实、待办清单模型只看到工作区内容而不是全部历史。这样既能控制 token 消耗也能减少模型注意力分散。第二个是工具路由。模型在推理时会产生“我需要调用某个工具”的意图但具体调用哪个工具、传什么参数不能直接在模型输出里裸奔否则格式稍微一变整个链路就崩。工具路由模块负责解析模型的工具调用意图把它映射到真实函数签名并处理参数缺失、类型不匹配等问题。它像是一个网关模型说“我要查天气”网关负责找到查天气的函数并格式化参数。第三个是状态机。Agent 任务通常有明确的状态转换初始 → 收集信息 → 决策 → 执行 → 校验 → 完成。状态机把每一步的进入条件和退出条件写死防止模型自己乱跳。比如用户还没上传图片Agent 就不能进入“识别图片”状态工具返回了异常Agent 不能直接进入“完成”状态。状态机是我觉得最容易被新手忽略的模块——没有状态机的 Agent 像一个没有剧本的演员经常演着演着就跑题了。第四个是安全护栏。这个模块负责在模型输出和工具执行之间加一道检查输出内容是否合规、传入工具的参数是否在安全范围、执行动作是否需要二次确认。做多模态 Agent 时这个尤其重要因为模型可能对图片内容产生误解进而触发不该触发的动作。护栏的作用不是限制模型能力而是让失控的概率降到可以接受的范围。2.3 最小 Harness 的长什么样一段能跑通的骨架我拿 Python 写过一个最小 Harness 的骨架核心思路并不复杂大家可以参考class Harness: def __init__(self, model_client, tools): self.model model_client self.tools {tool.name: tool for tool in tools} self.workdir {} self.state init def step(self, user_input): # 1. 组装上下文工作区 最近N轮对话摘要 context self.build_context(user_input) # 2. 调用模型返回文本或工具调用意图 response self.model.chat(context) # 3. 如果模型要求调用工具 if response.tool_calls: for call in response.tool_calls: tool self.tools.get(call.name) if not tool: self.workdir[last_error] funknown tool: {call.name} continue result self.safe_call(tool, call.arguments) self.workdir[call.name _result] result # 4. 把工具结果追加回上下文继续下一步 return self.step() # 5. 如果没有工具调用视为最终回复 self.state done return response.text def build_context(self, user_input): # 这里实现工作区内容 用户输入的组装 pass def safe_call(self, tool, args): # 这里做参数校验、权限检查、超时控制 pass这个骨架的核心逻辑是“循环”模型可以反复请求调用工具Harness 每次把工具结果放回工作区再让模型继续推理直到模型不再想调用工具为止。这其实就是 ReAct 模式的一种实现方式只不过把状态和工作区显式地管理起来了。提示千万不要把工具结果全部一股脑塞回历史记录。我吃过大亏——工具返回一个很大的 JSON直接塞回去模型瞬间“失忆”。工具结果应该经过摘要或筛选只保留与当前任务相关的部分。3. MCP把工具接入这件事标准化3.1 MCP 解决的是“方言遍地”的问题做 Agent 做到一定阶段你一定会遇到这个痛点每接入一个新工具就要为它写一套适配层。A 工具的接口是 REST 风格的B 工具是 WebSocket 的C 工具需要先登录才能拿 token。每个工具的参数格式、错误码、鉴权方式都不一样。写适配层的代码量甚至比写 Agent 核心逻辑还多而且每个项目里这些代码还不能互通换个环境就得重写。MCPModel Context Protocol就是为了解决这个“方言遍地”的问题而出现的。它定义了一套统一的协议工具提供方按照这套协议把能力暴露出来Agent 侧按照这套协议去发现和调用工具。就像 USB 接口一样无论你插的是键盘、U盘还是摄像头接口形状和通信协议是一致的设备就能即插即用。从架构上看MCP 分为三层Host 是 Agent 运行环境Client 是 Host 内负责发起请求的组件Server 是工具能力提供方负责把具体的业务能力包装成标准化的工具。这三层之间通过 JSON-RPC 消息通信支持请求、响应、通知三种消息类型。3.2 为什么不能直接写死 HTTP 调用有人会问既然工具都有接口我直接让 Agent 发 HTTP 请求不就行了吗答案是可以但只适合非常简单的场景。真实情况是一个 Agent 系统通常要接十几个工具每个工具都有不同的鉴权方式、不同的参数结构、不同的失败模式。直接在 Agent 里写死这些逻辑维护成本会随着工具数量线性上升。更麻烦的是很多工具不是简单“请求→响应”就完了它们有状态。比如一个多步骤审批流程你先提交申请然后轮询状态最后获取结果。这种流程如果用裸 HTTP 调用Agent 里就要写一大段轮询和状态解析逻辑而且这些逻辑和业务强耦合换一个相似但细节不同的工具又要重写。MCP 的做法是把工具调用抽象成“列出工具列表 → 获取工具 Schema → 调用工具 → 获取结果”的标准流程。工具的具体实现细节被封在 Server 里Agent 不需要知道这个工具是走 HTTP 还是 WebSocket不需要关心它内部怎么鉴权只需要按照协议发起调用并处理标准化响应。这个抽象带来的收益是巨大的接入新工具时只要对方提供了一个 MCP ServerAgent 侧几乎是零适配成本。3.3 写一个 MCP Server 没那么玄乎以一个最简单的“获取服务器时间”工具为例MCP Server 的核心代码大致是# 伪代码展示核心结构 def serve_tool(server): server.register_tool( nameget_server_time, description获取服务器当前时间, input_schema{ type: object, properties: { timezone: {type: string, description: 时区名称如 Asia/Shanghai} } }, handlerlambda params: {server_time: get_time(params.get(timezone))} )实际开发中你只需要实现三个关键接口list_tools返回这个服务提供了哪些工具call_tool接收工具名和参数并执行实际逻辑notification在状态变化时通知 Host。协议层、鉴权层、通信层都有现成的 SDK 处理你真正要写的只有业务逻辑本身。我在一个内部模拟项目里试过把一个已有的审批系统包装成 MCP Server再把一个文档系统也包装成 MCP Server然后让同一个 Agent 同时对接这两个系统。整个过程里Agent 侧代码一行没改只是换了 Server 地址。这种感觉真的很爽也是我后来坚定用 MCP 的原因。4. 缝合实操一个多模态 Agent 的完整链路4.1 场景设定单据识别与自动报销前面把三个概念单拎出来讲了现在把它们缝起来。我拿一个比较典型的场景来说明用户上传一张发票照片Agent 需要识别发票信息、查验真伪、生成报销单、提交审批最后把审批状态通知用户。这个场景同时用到了多模态识别、Harness 编排和 MCP 工具接入非常适合当完整例子。整个链路分四层第一层是接收图片并做预处理第二层是多模态模型识别第三层是 Harness 判断结果并编排下一步动作第四层是通过 MCP 调用查验、报销、通知等外部工具。4.2 第一层多模态识别与结构化输出收到用户上传的发票照片后我要做的第一件事不是直接扔给模型而是先检查图片质量。图片模糊、倾斜、反光、缺角这些都会影响识别效果。所以 Harness 在进入识别状态前会先做一次质量检测亮度是否正常、边缘是否清晰、是否需要旋转矫正。这个检测可以用传统图像处理算法做成本低速度快没必要动用大模型。通过质量检测后图片会被缩放到模型支持的分辨率范围内然后送入多模态模型要求它输出 JSON 格式的识别结果发票代码、发票号码、开票日期、购买方信息、价税合计等关键字段。这个环节有两个关键点一是 Prompt 里要对输出格式做严格约束告诉模型只能输出 JSON不能在 JSON 前后加任何解释文字二是在 Harness 里做一次 schema 校验防止模型返回缺字段或格式错误。4.3 第二层Harness 编排与工具调度识别完成后Harness 把结构化字段写入工作区状态从“识别完成”切换到“查验中”。然后 Harness 调用查验工具——这里走的就是 MCP。查验工具的参数是发票代码和发票号码返回值是真伪状态和基本信息。拿到查验结果后Harness 再做一次判断如果发票为真进入“填单”状态调用报销系统 MCP 工具创建报销单如果为假进入“拒绝”状态生成一条拒绝理由文本。这里的判断逻辑集中在 Harness 的状态机里模型不直接参与“要不要拒绝”的决策——这是安全护栏的作用防止模型被恶意 Prompt 影响而做出错误操作。报销单创建成功后Harness 把单号和状态信息写入工作区调用消息通知工具向用户推送“报销单已提交审批中”的通知。4.4 第三层一次会话完成多轮工具调用这个场景里Agent 一共调用了三个 MCP 工具查验发票、创建报销单、发送通知。如果不用 MCP每个工具都要单独写一个对接函数而且每个工具的返回格式都可能不一样Harness 就要为每个工具写一套解析逻辑。有了 MCP 之后工具描述、参数校验、返回解析都是统一的Harness 的核心逻辑就非常干净拿到工具意图、找工具、传参、收结果没有特殊情况。我还做了一点优化把三次工具调用放在同一次 MCP 会话里完成而不是每次重新建连。这样减少了握手的延迟整个流程跑下来大概能快半秒左右。这在用户体感上不明显但在批量处理场景里积累起来就很可观。4.5 成本与延迟的平衡多模态模型加上多次工具调用token 消耗不小。我在这个项目里做了三件事来控成本一是对图片做压缩和裁剪减少视觉 token二是用工作区机制限制上下文长度避免历史消息不断膨胀三是对模型输出做缓存——同一张发票如果被重复上传直接命中缓存结果不再调用模型。延迟方面最大的瓶颈其实在图片预处理和模型推理工具调用本身很快。所以我把图片预处理做成异步并行用户在等待识别结果的几秒里后端已经完成了多张图片的预处理排队。这样做下来单张发票从上传到推送通知的平均耗时控制在了五秒以内这在“识别 两轮工具调用 通知”的完整链路里已经算不错的成绩。5. 踩坑实录五个让我差点通宵的问题5.1 多模态模型的“一本正经胡说八道”第一次做发票识别时我发现模型偶尔会把发票号码里的“0”识别成“O”把“1”识别成“l”而且自己完全意识不到错。这个问题的根源在于视觉模型的字符级分辨力还没有做到像素级可靠特别在打印字体模糊的情况下。我的解决方案是在 Harness 里加了后校验对识别结果中的关键编码字段做规则校验比如发票号码必须全是数字、长度为固定位数。如果规则校验失败就自动进入第二次识别用不同 Prompt 重新提取一次。两次结果不一致时取置信度较高的一次。这个方法并不能百分之百消除错误但能把错误率降到可接受范围。注意多模态模型的输出不能作为“事实”直接使用尤其是在编码、数字、姓名这些精确字段上。务必要加规则校验或者用专门的 OCR 模型做二次确认。5.2 Harness 的状态丢失有一次我调整了 Harness 的工作区数据结构把“已收集字段”和“中间结果”合并成了一个 dict结果跑测试时发现 Agent 把用户之前上传的发票图片信息整个忘了。排查了半天发现是状态机切换时没有把工作区正确地传给下一个状态导致下一个状态读到的是一个空的工作区。这个问题的教训是Harness 的状态管理必须有明确的读写边界。每个状态只能访问它应该访问的字段并且在状态切换时要做一次序列化校验。后来我在 Harness 里加了一个状态快照功能每次切换状态前先记录当前工作区内容如果后续出错可以回滚排查问题方便很多。5.3 MCP 长连接的超时问题MCP 的 Server 和 Client 之间通常维持长连接但有时候网络波动会导致连接静默断开而双方都不自知。表现就是 Agent 侧等工具响应一直等Server 侧却觉得已经发过结果了。这个问题的排查难度很高因为没有任何报错。后来我在 MCP Server 里加了心跳机制每隔一段时间发送一次 pingClient 侧也加了响应超时检查超过一定时间没收到响应就主动重连并重新发起工具调用。还有一个更隐蔽的点MCP 请求如果包含大体积参数比如 base64 图片传输时间会很长可能导致超时阈值被误触。我把这个阈值调到了 60 秒并且对大体积参数做了压缩。5.4 token 配额失控在 Agent 循环里如果工具返回结果不断被加回上下文又没有做摘要处理几轮循环下来上下文就会爆炸。我踩过最夸张的一次一个简单的查询任务跑了七轮工具调用之后上下文长度是初始长度的 30 多倍模型输出质量急剧下降费用也飙升。解决方法是给每个工具结果设置“剩余价值阈值”如果工具结果对后续决策不再重要就只保留它的摘要如果完全不相关直接丢弃。这件事必须在 Harness 里显式规定不能指望模型自己决定。我还在 Harness 中加了上下文长度警告超过预设阈值时自动触发压缩或截断。5.5 多模态模型返回格式不稳定同一个模型在上一次请求里规规矩矩地返回 JSON下一次可能就在 JSON 外面套了一层 markdown 代码块或者在末尾多写了一句“以上是识别结果”。这种情况在小模型上尤其常见。我的做法不是在 Prompt 里反复强调“只能返回 JSON”因为这样效果有限。我更推荐在 Harness 里写一个容错解析函数先尝试直接解析 JSON失败则去掉 markdown 代码块标记再解析还失败就用正则抽取花括号内的内容再解析。同时记录解析失败率如果某个模型频繁格式混乱就要考虑更换模型或做模型间的降级切换。写在后面一些还在调整方向上的思考三个技术方向里多模态最直接Harness 最需要设计功力MCP 最值得行业层面大力推广。单看任何一个都是“锦上添花”的加分项组合起来才是 Agent 从演示走向生产的关键。我在实际项目中体会最深的一点是不要拘泥于“我该用哪个框架”而要盯住“我的控制系统到底控制了什么”。多模态接入做得再漂亮如果 Harness 的状态管理一塌糊涂整个 Agent 仍然是个不可靠的 DemoMCP 工具接得再多如果安全护栏没有想清楚这些工具就是隐患。如果你正准备开始做一个 Agent 项目我个人的建议是先让多模态跑通一个最小场景再用 Harness 把流程固化下来最后用 MCP 把外部工具接进来。不要一上来就搭一个巨大的平台你会被各种技术债压得喘不过气。从小场景出发踩过坑、补过漏再逐步扩展这个顺序几乎不会错。