☰
多模态、Harness与MCP:搭建稳定Agent工作流的关键实践
2026/10/13 5:24:48 网站建设 项目流程

上午我丢给 Agent 一张库存截图,让它“看看哪些 SKU 快断货了,顺便把补货申请单建好”。它“看懂”了截图里三列数据,却在调用补货工具时把“仓库 ID”填成了“商品条码”。我盯着报错日志愣了好几秒。

这种问题,几乎每个做 Agent 开发的人都遇见过。当模型不再只是回答你的问题,而是需要自己去观察、检索、调用工具、完成任务时,你很快会发现,真正难的不是让模型“理解”,而是搭一套能让它稳定干活的基础设施。这套东西,业内通常叫Harness,也就是 Agent 运行时/编排框架。而“看”这件事,涉及大模型多模态能力;“调用工具”这件事,现在越来越多的开发者用MCP(Model Context Protocol,模型上下文协议)做标准化。多模态、Harness、MCP,这三个关键词正好戳中了我这段时间技术笔记的核心。

这篇文章是我的个人实践记录,内容偏工程向:先讲 Harness 为什么存在,再说多模态输入如何在 Agent 内部流转,之后聊 MCP 能解决什么、不能解决什么,最后给出一个“看图→查库存→建补货单”的完整链路实测,以及我踩过的一些坑。如果你也在搭 Agent 框架,或者正被“模型会聊天但不会干活”折磨,这篇应该能帮到你。

1. 为什么 Agent 需要 Harness:从“问答模型”到“干活的人”的架构演进

1.1 从“prompt 进、文本出”到“自主工作循环”,中间缺的就是 Harness

最早我们调大模型,说白了就是“prompt 进,文本出”:写一段提示词,模型给你一段回答。这个模式用在聊天、翻译、文本归纳上没问题,但一旦任务变成“看一张截图,查询库存,再创建一张补货单”,事情就变了。

这不是一次性生成,而是一个多步骤的工作流:先理解截图,然后决定要查什么,调用查询工具拿到实时数据,对比截图和数据判断哪些需要补货,最后调用建单工具。每一步的结果都可能影响下一步的输入,模型自己没法独立完成这套循环,必须在外面有一个程序来负责:当前执行到哪一步了、上一步返回了什么、下一步该选哪个工具、工具返回的数据是否正确、整个过程有没有超出安全边界。

这个“外面的程序”就是 Harness。

我最早踩过的坑是把这个循环写死在业务代码里:用 if-else 把每个步骤串起来,模型只负责其中一小段。结果只要任务稍微变一下,代码就要改一遍。后来我意识到,需要把“任务的执行逻辑”从“具体业务规则”中抽离出来,让模型负责推理和决策,Harness 负责承载和约束。这就像把一个只会给固定配方炒菜的师傅,升级成一个能根据现有食材、厨具、食客口味临场发挥的主厨,而 Harness 就是后厨:备菜台、炉灶、调味料架、出锅检查,全都摆好了,主厨只需要决定怎么做。

1.2 一个可用 Harness 的最小骨架:五个模块缺一不可

我按“最小可用”原则拆过一个 Harness,长期稳定运行的版本里,下面五个模块是必须的:

  • 状态容器:保存当前任务上下文,包括所有历史消息、中间结果、已调用工具返回的数据、标志位。多模态场景下还要保存图像引用或压缩后的图像描述。
  • 工具注册表:所有 Agent 可用工具的描述、入参 Schema、调用入口。Harness 把这个注册表动态注入给模型,模型看到“有哪些工具、各干什么用”,才能做选择。
  • 推理循环:这是核心调度器。循环执行“组装上下文 → 调模型 → 解析模型输出 → 如果是工具调用则执行并回填结果 → 再组装上下文 → 继续调模型”,直到模型输出最终回答或达到最大步数。
  • 消息历史/记忆管理:负责裁剪、压缩、摘要历史消息,控制上下文长度。这个在纯文本场景就很重要,多模态之后更是生死线。
  • 审计与日志:记录每次模型调用、每个工具调用的入参、出参、耗时、token 消耗。没有这个,出了错只能对着黑盒猜。

这里给出一个我常用的最小推理循环伪代码,方便对照理解:

async def run_agent(task, tools, max_steps=10): state = AgentState(task=task, history=[]) for step in range(max_steps): context = build_context(state) response = await llm.chat(context, tools=tools) if response.has_tool_calls(): for call in response.tool_calls: result = await execute_tool(call) state.history.append(result) state.steps_taken += 1 continue if response.is_finish(): return response.content return "reach_max_steps_timeout"

build_context负责把任务描述、历史消息、上一次工具结果拼成模型输入;execute_tool则通过工具注册表找到对应实现。整个循环的关键在于:模型永远看到的是“加工过的上下文”,而不是原始日志,这样它能专注于下一步决策。

1.3 为什么我不把流程逻辑直接写进 Prompt

有人会说:既然模型这么聪明,把工具说明写在 Prompt 里让它自己调不就行了吗?我一开始也这么干过,后来放弃了,原因很现实:

  • 可控性。PrompT 里的规则是“软约束”,模型可能在某些措辞下绕过它。把关键逻辑放在 Harness 代码里是“硬约束”,比如工具白名单、参数校验、敏感操作二次确认,这些必须由代码强制保证。
  • 成本。所有工具说明如果全塞进系统提示,每次调用都在烧 token。Harness 可以按任务动态加载相关工具,而不是一股脑全给模型。
  • 可观测性。业务规则写死在 Prompt 里,出问题你没法快速定位是模型没遵守,还是工具本身返回了异常。Harness 结构化成日志后,排查路径清晰得多。
  • 安全边界。比如模型无论如何都不能调用“删除数据库”工具,这种限制必须写在 Harness 里,而不是期望模型自觉。

提示:把模型当“决策大脑”,不要把它当“流程引擎”。流程控制、权限校验、状态流转这些事情,交给 Harness 代码来做,模型只负责中间的判断和选择。

2. 多模态并不只是“能看图”:输入范式与内部数据流的重构

2.1 一张图在视觉语言模型内部到底经历了什么

多模态大模型(VLM)与传统纯文本模型最大的区别,是输入侧多了一个视觉编码器。图片进入模型后,会被切分成固定大小的图像块(patch),每个 patch 经过视觉编码器变成一个向量,再通过投影层映射到文本 token 的语义空间,与文本 token 拼成一个长序列,交给后面的 Transformer 做统一推理。

换句话说,模型并不是“看”到一张完整图片,而是把图片变成了“一长串视觉 token”,再和问题文本一起滚动计算。这也是为什么很多 VLM 在视觉推理任务上表现不错——因为它本质上是把“看图”转换成了“读一串带空间信息的 token 序列”,文本推理能力越强,视觉问答通常越稳。

但这也带来一个残酷的数学问题:一张高分辨率截图产生的 token 数量非常惊人。我用某个开源 VLM 做过实测,一张 1080P 的电商后台库存截图,在默认配置下大约会被折算成 3000 到 5000 个视觉 token,是同等长度文本问题的好几倍。这意味着,如果 Harness 直接把截图原图丢给模型,光是“看”这一步,就可能在 token 开销上吃掉大量预算。

2.2 在进入模型之前,图像应该先被“收拾”一遍

Harness 在多模态场景下新增的职责,就是对图像输入做前置处理。这一步做得好,能直接决定整个 Agent 的稳定性和成本。

我的处理管线一般是这样:

  • 尺寸归一化:超过模型支持的输入尺寸时,先等比缩放,避免模型内部暴力 resize 导致信息丢失。
  • 关键区域裁剪:如果任务只是“看表格里的库存数”,就没必要把整个页面截图都喂进去。可以先做一层对象检测/OCR 定位,把表格区域单独切出来,再送进模型。
  • OCR 预提取:对纯文字密集的截图,先跑一遍 OCR 得到结构化文本,很多时候比直接让模型读图更准、更省 token。
  • 图像压缩与格式转换:对非关键区域可降低分辨率,或转成 JPEG 压缩格式,减少传输体积。

我设计了一个多级视觉策略,Harness 根据任务类型自动选择:

场景处理策略效果
整页截图语义理解等比缩放到模型支持尺寸,直接送原图保真度最高,token 消耗最大
表格/文档类截图OCR 预提取 + 表格区域裁剪准确率提升,token 下降约 60%
UI 自动化点击关键区域裁剪 + 坐标标注定位更准,上下文更短
多图对比先单图理解汇总,再统一比较避免多图 token 叠加爆炸

这套策略落地后,库存截图任务的单次图片 token 开销,从原来的约 4000 降到了 1200 左右,而模型回答准确率反而高了。原因也很简单:模型接收的是更干净、更聚焦的信息,而不是一整张无关元素堆叠的截图。

2.3 成本账单:多模态输入到底贵在哪

我给团队算过一次账。原先纯文本 Agent 处理一个任务平均消耗约 2000 token;接入多模态后,单张截图按 4000 token 计,再加上完成工具调用需要的多轮推理,整个任务轻松破万。按当前主流模型 API 价格粗算,单任务成本从几分钱涨到几毛钱,翻了 5 到 8 倍。

这不是一个可以忽略的数量级。实际工程里,我做了三件事来压成本:

  1. 分层缓存。同一张截图如果在短时间内被多次请求,Harness 直接复用首次解析的结构化结果,不再重复送模型。
  2. 文本优先。能通过 OCR、日志、接口拿到的文本信息,绝不走图像理解;只有文本拿不到的关键信息才送图。
  3. 延迟视觉加载。在任务开始阶段先不加载图片,等模型判断“需要看图”时再注入图像 token,避免无效的视觉开销。

多模态不是一个“开箱即用”的能力,它需要 Harness 层面围绕它重新设计数据流。在我目前这套架构里,“图”已经不是简单的用户输入,而是一类需要被管理、裁剪、缓存、校验的资源对象。

3. MCP 的价值不是省掉胶水代码,而是把工具变成“可发现的资源”

3.1 我为什么从“自写工具层”转向 MCP

在做 Agent 的早期,我接工具的方式很原始:每个工具写一个 Python 函数,然后手写一段 JSON Schema 描述参数,再在系统提示里拼一段“你可以调用这些工具:…”的说明。模型输出一个 JSON,我用正则和类型断言去解析。

这套东西能跑通,但维护起来相当痛苦。新加一个工具,要写函数、写 Schema、写 Prompt 片段、写解析逻辑、写异常处理;工具多了之后,模型经常把 A 工具的字段填到 B 工具上,因为描述不够清晰、互相干扰。后来 MCP 出现,我花了两周时间把内部工具层全部迁了过去,体会很深。

MCP(Model Context Protocol)是某头部 AI 实验室在 2024 年底提出的开放协议,设计目标就是标准化“模型应用”与“外部工具/数据源”之间的对接。你可以把它理解成 AI 界的 USB 接口:以前每接一个新设备,都要专门配一根不兼容的线;现在设备统一做成 USB 接口,插上就能用。MCP 就是那根统一线。

3.2 MCP 的协议设计核心:工具发现、调用、资源读取

MCP 基于 JSON-RPC 2.0,定义了客户端(Client,运行在 Harness 侧)和服务端(Server,封装具体工具)之间的通信方式。最重要的三组能力:

  • tools/list:客户端向服务端请求“你有哪些工具”,服务端返回每个工具的名称、描述、入参 Schema。这是“工具可发现”的根基。
  • tools/call:客户端按名称和参数调用具体工具,服务端执行后返回结构化结果。
  • resources/read:读取服务端暴露的数据资源,比如文件内容、数据库查询结果。

传输层有两种主流方式:

传输方式适用场景特点
stdio本地子进程,服务端与 Harness 同机零网络配置,适合本地工具链
Streamable HTTP(SSE)远程服务,跨机器调用网络传输,适合微服务化的工具集群

在 Harness 里,MCP 客户端负责维护连接生命周期、调用会话、错误恢复。服务端则只需要实现工具逻辑和 Schema 声明,不用关心上层模型怎么使用。这一层解耦带来的直接好处是:新接一个工具,我不需要动 Harness 主流程,只需要新增一个 MCP Server,然后用客户端一连接,工具就“出现”在注册表里了。

3.3 接入一个 MCP Server 的完整过程

我用官方 Python SDK 写过一个极简 MCP Server,用来暴露两个库存相关工具,核心代码如下:

from mcp.server import Server, stdio_server from mcp.types import Tool, TextContent app = Server("inventory-tools") @app.list_tools() async def list_tools(): return [ Tool( name="query_stock", description="按仓库ID查询当前库存,返回SKU列表及剩余数量", inputSchema={ "type": "object", "properties": { "warehouse_id": {"type": "string", "description": "仓库ID,例如 WH-01"} }, "required": ["warehouse_id"] } ), Tool( name="create_replenish_order", description="为指定SKU创建补货申请单", inputSchema={ "type": "object", "properties": { "sku_id": {"type": "string"}, "warehouse_id": {"type": "string"}, "quantity": {"type": "integer"} }, "required": ["sku_id", "warehouse_id", "quantity"] } ) ] @app.call_tool() async def call_tool(name: str, arguments: dict): if name == "query_stock": result = await db.query_stock(arguments["warehouse_id"]) return [TextContent(type="text", text=json.dumps(result))] if name == "create_replenish_order": order_id = await db.create_order(arguments) return [TextContent(type="text", text=f"created:{order_id}")] raise ValueError(f"Unknown tool: {name}") async def main(): async with stdio_server() as (read, write): await app.run(read, write) if __name__ == "__main__": asyncio.run(main())

在 Harness 侧,调用方式更简单:起一个 MCP 客户端连接到该 Server,执行list_tools拿到工具清单,转成模型可识别的工具描述,注入上下文;后续模型若有调用意图,直接转发call_tool请求即可。整个过程里,Harness 完全不关心服务端内部实现细节,这就是统一协议带来的爽感。

3.4 “直接写函数”与“MCP”的对比:什么时候用哪个

我常被问一个问题:MCP 相比直接在代码里调用函数,有什么本质区别?我列个表说明:

对比维度直接函数调用MCP 接入
新增工具成本改 Harness 代码、加 Schema、测全链路写独立 Server,客户端自动发现
工具描述可见性依赖人工维护 Prompt由 Server 声明,动态注入
跨语言/跨进程仅限同进程内实现天然支持进程/机器边界
可观测性需自建日志协议自带请求/响应结构
对模型侧统一性每个工具风格可能不一致统一的工具发现与调用语义

但 MCP 也不是银弹。对于延迟敏感、调用极其频繁、且和 Harness 强耦合的内部函数,比如内存状态读取、超轻量计算,直接函数调用依然是更好的选择。MCP 更适合的是“需要被模型发现和选择”的外部能力,尤其是数据库查询、HTTP API、文件操作、第三方服务这类边界清晰的工具。

4. 三个组件凑在一起:我搭建一个“看图→查库存→创建补货单”Agent 的实测记录

4.1 这次我想做什么

裁掉理论,我直接在本地搭了一个最小可跑的链路,目标场景是:给 Agent 一张电商后台库存截图,它需要自己看出哪些 SKU 库存低于警戒线,然后调用库存查询工具核对实时数据,最后为缺货 SKU 创建补货单。整条链路涉及:

  • 多模态模型:负责从截图中提取 SKU 列表和库存数。
  • Harness:负责任务循环、状态管理、动态工具注入、步骤控制。
  • MCP:把“查询库存”和“创建补货单”封装成两个独立工具服务。

链路顺序是:用户传入截图 → Harness 预处理并构建上下文 → 多模态模型输出结构化数据 → Harness 调用 MCP 客户端 → MCP Server 执行工具并返回结果 → Harness 把结果回填给模型 → 模型决策下一步 → 最终输出。

4.2 核心代码:Harness 主循环 + MCP 客户端接入

我先把 Harness 主循环中的多模态处理部分抽出来,让大家直观感受一下上下文组装方式:

async def build_visual_context(image_path: str, ocr_mode: bool = True): pil_image = preprocess_image(image_path) # 缩放、裁剪 if ocr_mode: text_block = await ocr_extract(pil_image) # OCR预提取 return { "image_tokens": None, "text_block": text_block, "visual_summary": "库存表格,含SKU、名称、当前库存、警戒值四列" } else: return { "image_tokens": pil_image, # 直接走视觉token "text_block": None, "visual_summary": "原图直接送视觉模型" }
async def run(task_text, image_path): state = AgentState(task_text=task_text) visual = await build_visual_context(image_path) state.history.append({ "role": "user", "image_text": visual["text_block"], "visual_hint": visual["visual_summary"] }) tools = await mcp_client.list_tools() # 通过MCP自动发现 context = render_with_tools(state, tools) for _ in range(8): resp = await vlm.chat(context, tools=tools, max_tokens=1024) if resp.tool_calls: for call in resp.tool_calls: result = await mcp_client.call_tool(call.name, call.arguments) state.history.append({"role": "tool", "name": call.name, "result": result}) context = render_with_tools(state, tools) continue return resp.content raise TimeoutError("agent steps exceeded")

这段代码把三个组件粘在了一起。MCP 客户端负责工具发现和调用,模型只负责生成“下一步做什么”的决策,Harness 负责循环和控制。跑通这个 Demo 后,我有几个很直观的观察:

观察一:模型“看懂”截图 + 找到正确工具的可靠性,比纯文本时代高一个台阶。因为 OCR 预提取已经把表格变成了文本,模型不用靠视觉推理去猜数字,而是直接在文本上计算,准确率明显提升。

观察二:新增工具的速度确实快了。我后来又给 MCP Server 加了一个“获取仓库列表”的工具,Harness 端没改一行代码,重新连接后工具自动出现在模型可选择的清单里。

观察三:日志好查了。每步是“模型请求”还是“工具调用”,一目了然。以前出错要翻半天,现在直接看 MCP 的 tools/call 入参就能定位。

4.3 一个典型执行过程的逐步时间线

为了给没有跑过的读者一个具象认知,我把一次成功执行的步骤时间线列出来:

  1. 用户上传截图,Harness 预处理:OCR 提取出 7 行 SKU 数据。
  2. 多模态模型读取 OCR 文本,判断需要调用query_stock工具核对“仓库 WH-01”的实时库存。
  3. MCP Server 返回实时库存 JSON,模型逐一对比发现 3 个 SKU 低于警戒线。
  4. 模型生成补货数量,调用create_replenish_order创建补货单。
  5. 服务端返回订单号,模型汇总结果输出最终回复。

整个过程耗时约 12 秒,共 4 次模型请求、3 次工具调用,token 总消耗约 8500。这个数据比我预想的要好,主要归功于 OCR 预提取把图片 token 压缩掉了。

5. 实测中的三个坑与完整排查链路

5.1 坑一:模型“看得懂图,却选不对工具”——级联误差排查

这个坑最让人抓狂。现象是:模型能从截图中正确识别出“SKU 数量不足”,但在调用query_stock时,把warehouse_id填成了商品条码,导致查询结果为空,任务直接失败。

我的排查链路分四步:

  • 第一步,看模型原始输出。打印完整 tool_calls JSON,确认模型确实打算调用正确的工具,只是参数填错了。
  • 第二步,反查入参 Schema。发现warehouse_id的描述就一句话“仓库ID”,没有给示例,也没有注明“形如 WH-01”。模型看到截图里既有“仓库A”又有“SKU12345”,在字段语义模糊的情况下,就会填错。
  • 第三步,回看 OCR 输出。发现 OCR 文本里“仓库号”那一列的表头识别成了“库存地”,表格语义被削弱。
  • 第四步,修复。我做了两件事:一是把工具字段描述改成带枚举和示例的写法,明确"SKU"与"仓库ID"的含义和格式;二是给多模态模型追加一条提示:“注意区分仓库ID与SKU编号,仓库ID通常以 WH 开头。”修完重测,通过率从 62% 提到 95%。

这个坑的本质是级联误差:多模态理解是第一层误差源,工具参数映射是第二层误差源,两层一叠加,失败率就会被放大。解决办法是拆层收敛:每一层都加约束和校验,让误差在每层就被拦下来,而不是一路传导到最后。

5.2 坑二:MCP 工具描述太长引发的上下文污染

把 50 个工具全接进 MCP 后,我发现模型开始频繁选错工具,甚至出现幻觉工具名。排查后怀疑是系统提示里的工具描述太长,模型在超长上下文里注意力被稀释。

简化的验证方法是:固定任务,只保留任务相关工具(比如只留 4 个库存工具),重测,选对率从 71% 直接升到 93%。这证实了“工具清单越长,选择越不准”。

我的长期解法是动态工具分组:MCP Server 在list_tools之外,额外暴露一个list_tool_groups能力,Harness 根据任务关键词只加载相关分组的工具。比如“补货”任务只加载库存、订单、仓库三个分组;无关的人事、财务工具完全不进入上下文。这样既保留了 MCP 工具可发现的优点,又控制了上下文长度。另外,给每个工具写描述时,前 15 个字就要说明白它是什么,详细规则和示例放在后面,让模型注意力聚焦在关键信息上。

5.3 坑三:Harness 中的多模态状态过期问题

多模态 Agent 有个隐藏问题:图像是静态的,但它描述的世界是动态的。我的 Agent 在跑长任务时,出现过一种情况:模型在第一步看了截图 A,认为 SKU 缺货,但等它执行完前两个工具调用后,实际库存已经因为其他人并发操作而补上了。模型不知道,继续按旧截图的数据建补货单,造成重复补货。

这个问题的根因不在模型,而在 Harness 的状态管理设计——图像状态没有“新鲜度”概念。

我加了三个机制:

  • 数据新鲜度标记。图像理解结果附带captured_at时间戳,后续工具调用前检查时间差。
  • 工具结果强校验。create_replenish_order在创建前先查一次实时库存,若实时量已高于警戒线则拒绝创建并返回说明。
  • 主动重新观察。模型在重要决策前,如果判断环境可能变化,会主动触发“重新抓取截图/刷新图像”动作。

加了这三层之后,类似问题基本被焊死。这里也想提醒大家:多模态状态处理,不是一次性输入,而是一个持续跟踪的资源生命周期问题。

6. 一些来自一线的选型决策与路线建议

6.1 我目前的选型决策表

搭建这套系统时,我在几个关键点上做了取舍,整理成表供参考:

选型项我的选择原因与场景
多模态模型开源 7B~13B 级 VLM可控、可本地化部署,数据不出内网
图像预处理深度依赖 OCR + 区域裁剪表格/文档场景下准确率高且 token 开销小
Harness 实现自研轻量框架团队内部任务固定,自研可控性更强
MCP 传输层stdio 为主,远程用 Streamable HTTP本地工具链为主,无额外网络故障点
工具注册方式MCP 动态分组加载控制上下文长度,避免工具选择漂移

如果你没有自研诉求,也可以先用社区成熟的 Harness 框架再逐步替换;但如果你的任务里有大量私有业务逻辑,自研 Harness 的门槛并没有想象中高,核心就一个循环加状态容器。

6.2 如果让我从头再搭一遍,我会按什么顺序走

第一遍做的时候我痛苦很大,因为一上来就铺了十个工具、接了三个外部系统,结果哪里出错都不知道。如果再来一次,我会按这个顺序推进:

  • 第一阶段:只跑通“文本进、文本出”的单步模型调用,Harness 先不接任何工具。
  • 第二阶段:加三个简单工具,走通“模型选择工具→执行→回填→继续推理”的最小循环。
  • 第三阶段:把工具封装成 MCP Server,验证动态发现和调用。
  • 第四阶段:加多模态。先只做截图输入 + OCR 预提取,不急着直接上视觉 token。
  • 第五阶段:再做工具分组、状态新鲜度、缓存这些进阶能力。

每个阶段都有明确的可验证目标,出问题时只需要看当前阶段引入的变量,定位会快很多。

最后分享一个非常个人化的体会:多模态、Harness、MCP 这三样东西,单独拿出来看都不难,难的是把它们组合进一条完整的工作链路里。模型负责聪明,Harness 负责稳定,MCP 负责整洁,三者各司其职之后,Agent 才真正开始像一个靠谱的同事,而不是一个偶尔灵光、经常出错的玩具。这套架构我还会继续迭代,尤其是状态新鲜度校验和工具分组策略这两块,后续值得专门再写一篇记录。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询