1. 从 Demo 到生产:Agent 落地最容易被低估的深水区
做 Agent 的人大多经历过这样一个阶段:本地跑通一个 ReAct 循环,接上几个工具,看着模型自己规划、调用、反思,觉得“这东西成了”。然后老板说,接一下公司的 OA 和 ERP 吧,让 Agent 能查审批、能建单据、能拉库存。这时候你才发现,前面那些调 Prompt、换模型、加 Memory 的活儿,加起来可能只占整个工程量的三成,剩下七成全卡在“怎么让 Agent 安全、稳定、可维护地碰到企业里那套跑了十年的老系统”。
这个项目标题里的“FDE MCP Blade”,拆开看其实是三个关键词的叠加:FDE(Forward Deployed Engineer,前置交付工程师)、MCP(Model Context Protocol,模型上下文协议)、Blade(这里我理解为一把用来“切开”企业系统边界的工具层)。合在一起,它描述的是一类非常具体的工程实践——由 FDE 角色主导,用 MCP 协议作为桥梁,把 OA、ERP 这类企业核心系统封装成 Agent 可调用的能力,并且这套东西是要上生产的,不是演示用的。
为什么说难的不是模型?因为模型能力在过去一年里已经卷到“够用”了。你拿一个中等规模的模型,配上好的工具描述和 Few-shot 示例,它在单步工具调用上的准确率可以做到很高。但企业系统不一样:OA 的审批流有几十种状态,ERP 的单据有复杂的必填字段和联动逻辑,接口可能是 SOAP 的、可能是私有协议的、可能压根没有 API 只能走页面。Agent 面对的不是“选哪个工具”,而是“这个工具背后那套业务规则,我该怎么让它理解并且不闯祸”。
这篇文章适合三类人看:一是正在做 Agent 落地、被企业系统集成卡住的工程师;二是 FDE 角色或者想转 FDE 的开发者,想了解这类岗位到底在干什么;三是技术负责人,在评估 Agent 上生产的可行性和成本。我会从架构设计、MCP 封装、OA/ERP 对接实操、并发与安全、问题排查几个维度,把这件事拆开讲透。里面有不少是我自己踩过的坑,也有一些是跟同行交流时验证过的做法,你拿去就能对照着改。
2. 整体架构设计:为什么是 MCP + FDE 这个组合
2.1 FDE 角色的真实定位:不是售前,也不是纯后端
FDE 这个词最早在 Palantir 那套体系里被广泛提及,国内腾讯等公司也在推类似的课程和岗位。很多人把它理解成“懂技术的售前”,这个理解偏了。FDE 的核心价值在于:他站在客户业务现场,把通用技术能力翻译成解决具体业务问题的方案,并且亲手把它交付上线。
放到 Agent 接 OA、ERP 这个场景里,FDE 要干的事包括:搞清楚客户 OA 里“请假审批”这个流程到底有几个节点、每个节点的操作权限是什么、ERP 里“采购订单”创建时哪些字段是必填、哪些字段有联动校验、接口的鉴权方式是什么、有没有频率限制。这些信息不会写在公开文档里,得去问、去试、去翻旧代码。然后 FDE 要把这些业务规则抽象成 Agent 能理解的工具描述,用 MCP 协议封装好,再跟 Agent 的编排层对接。
为什么不让纯后端来做?因为纯后端容易陷入“接口能通就行”的思维,而 Agent 需要的是“语义层面的可用”。比如一个“查询库存”的接口,后端返回一个 JSON 数组就完事了,但 Agent 需要知道:这个库存数是实时还是缓存、单位是什么、负库存代表什么、多个仓库时怎么选。这些语义信息,必须由懂业务的人来补。
2.2 MCP 协议到底解决了什么问题
MCP 是 Anthropic 提出的一个开放协议,全称 Model Context Protocol。你可以把它理解成“AI 模型和外部工具之间的 USB-C 接口”。在 MCP 之前,每个 Agent 框架都有自己的工具定义方式:LangChain 有 Tool 类,OpenAI 有 Function Calling 的 JSON Schema,各家 IDE 又有自己的插件机制。你为一个系统写好的工具,换个框架就得重写一遍。
MCP 把这件事标准化了。它定义了一套基于 JSON-RPC 的通信规范,工具提供方(MCP Server)只需要按照协议暴露自己的能力,任何支持 MCP 的客户端(Claude Desktop、各种 IDE、自研 Agent 框架)都能直接调用。对于企业系统集成来说,这意味着:你为 OA 写一套 MCP Server,以后不管上层 Agent 用什么框架,这套 Server 都能复用。
MCP 的核心概念有三个:Tools(可调用的函数)、Resources(可读取的数据源)、Prompts(预置的提示模板)。接 OA、ERP 主要用到 Tools,偶尔用 Resources 来暴露一些静态配置。协议本身支持 stdio 和 SSE 两种传输方式,生产环境里 SSE 更常见,因为可以跨网络部署。
注意:MCP 是软件协议,不是硬件协议。经常有人问“MCP 是不是像 USB 那样的硬件标准”,不是的,它是应用层的通信协议,跑在 TCP 之上。
2.3 为什么不用直接 Function Calling 而要套一层 MCP
有人会问:我直接用 OpenAI 的 Function Calling 或者 LangChain 的 Tool 不就行了,为什么要多一层 MCP?这个问题我在项目初期也纠结过。直接 Function Calling 的优势是简单,少一层转发,延迟低。但上了生产之后,MCP 的价值就体现出来了:
第一,解耦。Agent 的编排逻辑和工具的实现逻辑分开。OA 的接口变了,只需要改 MCP Server,Agent 侧不用动。第二,复用。同一个 MCP Server 可以同时给多个 Agent 用,也可以给不同的 IDE 用。第三,可观测。MCP Server 是一个独立的进程或服务,你可以单独给它加日志、加监控、加限流,而 Function Calling 是嵌在 Agent 进程里的,不好隔离。第四,安全边界。MCP Server 可以部署在内网,只暴露必要的工具,Agent 跑在外面也碰不到核心系统。
代价是多了网络跳转和序列化开销。实测下来,一次工具调用的额外延迟在 20-50ms 左右,对于 OA、ERP 这种本身响应就在几百毫秒的系统来说,可以接受。
2.4 整体架构分层
我把这套东西分成四层,从下往上:
| 层级 | 职责 | 典型组件 |
|---|---|---|
| 系统层 | 企业原有系统 | OA、ERP、CRM、数据库 |
| 适配层 | 协议转换、鉴权、字段映射 | MCP Server、适配器 |
| 编排层 | 意图理解、工具选择、多步规划 | Agent 框架 |
| 交互层 | 用户入口 | 对话界面、IDE 插件、API |
FDE 主要工作在适配层,偶尔碰编排层的工具描述优化。这个分层的好处是,每一层可以独立测试和替换。适配层用 Mock 数据就能测,不用连真实 OA。
3. OA 系统对接实操:从鉴权到审批流封装
3.1 先搞清楚 OA 的鉴权体系
国内 OA 系统五花八门,泛微、通达、致远、蓝凌各有各的玩法。但鉴权方式逃不出这几类:Session/Cookie、Token、CAS 单点登录、OAuth2。热搜词里出现了“通达 OA CAS”和“泛微 OA 建模引擎”,说明这两个是高频场景。
通达 OA 的 CAS 是比较典型的一种。它的流程是:Agent 侧先访问 CAS 登录接口,带上用户名密码或者票据,拿到一个 ticket,再用 ticket 去换 session。这里有个坑:CAS 的 ticket 是一次性的,且有效期很短,通常几十秒。如果你的 MCP Server 每次调用都重新走一遍 CAS,延迟会很高,而且容易触发风控。正确做法是在 MCP Server 里维护一个 session 池,登录一次拿到 session 后缓存起来,定期续期。
泛微 OA 的鉴权相对简单一些,很多版本支持直接传 token 或者用固定的 appid/secret 换 token。但泛微的建模引擎有个特点:它的接口是按“建模”维度组织的,你得先知道目标模块的 modelId 和对应的字段定义,才能构造请求。这些信息在 CSDN 上有很多人分享,但版本差异大,最好还是从自己的环境里导出来。
# 通达 OA CAS 登录的简化示例 import requests import time class TongdaOAClient: def __init__(self, base_url, username, password): self.base_url = base_url self.username = username self.password = password self.session = None self.session_expire = 0 def _login(self): # 第一步:获取 CAS 登录页的 execution 参数 login_page = requests.get(f"{self.base_url}/cas/login") # 解析出 execution 值(实际代码需要用 BeautifulSoup 或正则) execution = self._parse_execution(login_page.text) # 第二步:提交登录 resp = requests.post( f"{self.base_url}/cas/login", data={ "username": self.username, "password": self.password, "execution": execution, "_eventId": "submit" }, allow_redirects=False ) # 第三步:从重定向中提取 ticket,再换 session ticket = self._extract_ticket(resp.headers.get("Location", "")) session_resp = requests.get( f"{self.base_url}/cas/serviceValidate", params={"ticket": ticket, "service": f"{self.base_url}/oa"} ) self.session = session_resp.cookies self.session_expire = time.time() + 1800 # 假设 30 分钟有效 def ensure_session(self): if not self.session or time.time() > self.session_expire - 60: self._login() return self.session这段代码的关键点在于ensure_session的提前续期逻辑。我设的是过期前 60 秒就重新登录,避免边界情况。实际跑下来,这个策略比每次调用都登录稳定得多。
3.2 审批流封装:把状态机翻译成工具
OA 最核心的能力是审批流。一个请假流程可能有“发起-部门经理审批-HR审批-归档”四个节点,每个节点有不同的操作权限。Agent 要能查审批状态、能发起审批、能审批(如果用户授权的话)。
这里最大的难点是:审批流的状态是动态的,不同流程、不同组织架构下节点名称和数量都不一样。你不能硬编码“部门经理审批”这个节点,因为有的公司叫“直属领导审批”,有的公司是两级审批。
我的做法是:在 MCP Server 里封装一个get_approval_status工具,输入是流程实例 ID,输出是一个结构化的状态对象,包含当前节点、待办人、已办节点列表、可执行的操作。然后在工具描述里告诉 Agent:这个工具返回的available_actions字段列出了当前用户可以执行的操作,你只能从这里面选。
{ "name": "get_approval_status", "description": "查询指定审批流程的当前状态。返回当前节点、待办人、已办节点和可执行操作。注意:只能执行 available_actions 中列出的操作。", "inputSchema": { "type": "object", "properties": { "process_id": { "type": "string", "description": "审批流程实例 ID,通常以 FLOW_ 开头" } }, "required": ["process_id"] } }这个描述里我特意加了一句“只能执行 available_actions 中列出的操作”,这是为了防止 Agent 幻觉出一个不存在的操作去调用。实测下来,加了这句话之后,误操作率明显下降。
3.3 泛微建模引擎的字段映射坑
泛微的建模引擎是很多企业做自定义表单的基础。它的接口返回的字段名往往是field_123这种内部 ID,而不是人类可读的leave_type。Agent 拿到这种数据会懵,因为它不知道field_123是什么。
解决办法是在 MCP Server 里做一层字段映射。启动时从泛微的元数据接口拉取字段定义,缓存到内存里,返回给 Agent 之前把field_123转成请假类型。这个映射表要定期刷新,因为业务人员可能随时改表单。
实操心得:泛微的元数据接口有时候会返回缓存数据,改了表单之后不立即生效。我一般会在 MCP Server 里加一个手动刷新映射的接口,改完表单后调一下,不用重启服务。
还有一个坑是“泛微 OA 添加外部地址作为目录报错连接被阻止”。这个错误通常是因为泛微的安全策略限制了外部 iframe 或跳转。如果你要在 OA 里嵌入 Agent 的对话界面,需要把 Agent 的域名加到泛微的白名单里,具体位置在系统管理-安全设置-可信站点。这个配置项藏得比较深,第一次找要花点时间。
4. ERP 系统对接:比 OA 更硬的骨头
4.1 ERP 接口的三种形态和应对策略
ERP 比 OA 难接,因为 ERP 的数据模型更复杂、业务规则更严格、接口更不统一。我见过的 ERP 接口大概分三种:
第一种是标准 REST/SOAP API。金蝶、用友的新版本都提供了比较完整的 API,有文档、有 SDK。这种最好办,直接封装成 MCP Tool 就行。但要注意:ERP 的 API 通常有严格的字段校验,少一个必填字段就报错,而且错误信息往往很模糊。我的做法是在 MCP Server 里做一层预校验,把必填字段检查提前,返回更清晰的错误提示给 Agent。
第二种是数据库直连。很多老 ERP 没有 API,只能读数据库。这种最危险,因为直接读生产库可能锁表、可能读到中间状态的数据。如果非要用,一定要走只读从库,并且加查询超时和行数限制。写操作绝对不要直连数据库,必须走应用层。
第三种是页面自动化。有些 ERP 只有 Web 界面,没有 API。这时候可以用 Playwright MCP 或者 Browser Use MCP 来做页面操作。热搜词里出现了“playwright mcp”和“browser use mcp 跟 playwright mcp 有什么区别”,说明这是很多人在探索的方向。
4.2 Playwright MCP 和 Browser Use MCP 的选型
这两个都是让 Agent 操作浏览器的方案,但定位不同。Playwright MCP是把 Playwright 的能力封装成 MCP 工具,Agent 可以调用click、fill、navigate等原子操作。Browser Use MCP更上层一些,它让 Agent 用自然语言描述目标,内部自己规划操作步骤。
接 ERP 页面的话,我倾向于用 Playwright MCP,原因是可控。ERP 的操作往往有严格的顺序和确认步骤,比如“先选仓库,再选物料,再填数量,最后点保存”,每一步都不能错。Playwright MCP 的原子操作让 Agent 可以精确控制每一步,而 Browser Use MCP 的自主规划在复杂页面上容易跑偏。
# Playwright MCP Server 中一个工具的定义示例 @mcp.tool() async def fill_erp_field(field_label: str, value: str) -> str: """在 ERP 页面中填写指定标签的字段。 Args: field_label: 字段的显示标签,如"物料编码"、"数量" value: 要填入的值 """ # 通过 label 文本定位输入框 locator = page.get_by_label(field_label) if await locator.count() == 0: return f"未找到标签为'{field_label}'的字段" await locator.fill(value) return f"已将'{field_label}'填写为'{value}'"这个工具的设计要点是:用标签文本定位而不是 CSS 选择器。因为 ERP 页面的 DOM 结构经常变,但标签文本相对稳定。而且标签文本是 Agent 能理解的,它知道“物料编码”是什么,但不知道#ctl00_ContentPlaceHolder1_txtMaterialCode是什么。
4.3 ERP 单据创建的字段联动处理
ERP 最烦人的是字段联动。比如你选了“采购订单”类型,物料字段的可选范围就变了;你填了数量,单价可能自动带出来;你选了税率,总价自动计算。这些联动在页面上是 JavaScript 处理的,但通过 API 调用时不会自动触发。
我的处理方式是在 MCP Server 里模拟这些联动逻辑。具体做法是:先从 ERP 的配置表或者前端代码里把联动规则扒出来,然后在 Server 里实现一个validate_and_fill函数,在提交之前把该算的算好、该填的填上。
def prepare_purchase_order(data: dict) -> dict: """根据 ERP 的联动规则补全采购订单字段""" # 规则1:根据物料编码带出默认供应商 if data.get("material_code") and not data.get("supplier_code"): data["supplier_code"] = get_default_supplier(data["material_code"]) # 规则2:根据数量和单价计算金额 if data.get("quantity") and data.get("unit_price"): data["total_amount"] = round( float(data["quantity"]) * float(data["unit_price"]), 2 ) # 规则3:根据供应商和物料确定税率 if data.get("supplier_code") and data.get("material_code"): data["tax_rate"] = get_tax_rate( data["supplier_code"], data["material_code"] ) return data这些规则不是拍脑袋写的,是从 ERP 的前端 JS 和数据库触发器里一点点扒出来的。扒的过程很痛苦,但扒完之后 Agent 创建单据的成功率从不到 50% 提升到了 95% 以上。
注意:不同 ERP 版本的联动规则可能不同,升级 ERP 后一定要重新验证。我遇到过升级后税率计算方式变了,导致一批单据金额错误的情况。
4.4 金蝶 ERP 操作手册里的隐藏信息
金蝶的官方操作手册写得很详细,但它是写给最终用户看的,不是写给集成开发者看的。手册里不会告诉你:某个字段在 API 里的名称是什么、某个操作的幂等性如何、并发调用会不会有问题。
我的经验是:手册看流程,代码看细节。先把手册里的业务流程搞清楚,知道“采购订单从创建到审核要经过哪些步骤”,然后去翻金蝶的 API 文档和 SDK 源码,找到每个步骤对应的接口和参数。金蝶的 SDK 里有一些注释会透露关键信息,比如某个接口是否支持批量、是否有事务保证。
还有一个技巧:金蝶的演示环境(如果有的话)可以用来做破坏性测试。比如故意传错参数看报错信息、并发调用看会不会锁、重复提交看是否幂等。这些信息在正式环境里不敢试,在演示环境里可以随便折腾。
5. 并发、安全与可观测性:上生产必须过的三关
5.1 Agent 怎么扛并发
热搜词里有“ai agent 怎么扛并发”,这是上生产绕不开的问题。Agent 的并发和普通 Web 服务的并发不一样,因为 Agent 的一次请求可能包含多轮模型调用和多次工具调用,耗时可能是几十秒甚至几分钟。
我的做法是分层限流:
| 层级 | 限流对象 | 策略 |
|---|---|---|
| 接入层 | 用户请求 | 按用户维度限流,防止单用户刷 |
| 编排层 | Agent 实例 | 控制同时运行的 Agent 数量 |
| 工具层 | MCP Server | 按工具维度限流,保护后端系统 |
| 系统层 | OA/ERP | 遵守对方的频率限制 |
工具层的限流最关键。OA 和 ERP 通常有并发限制,你并发太高会被封 IP 或者拖垮系统。我在 MCP Server 里给每个工具配了独立的信号量,比如查询类工具允许 10 并发,写入类工具只允许 2 并发。
import asyncio class RateLimiter: def __init__(self, max_concurrent: int): self.semaphore = asyncio.Semaphore(max_concurrent) async def __aenter__(self): await self.semaphore.acquire() return self async def __aexit__(self, *args): self.semaphore.release() # 查询工具允许 10 并发 query_limiter = RateLimiter(10) # 写入工具只允许 2 并发 write_limiter = RateLimiter(2) @mcp.tool() async def query_inventory(material_code: str) -> dict: async with query_limiter: return await erp_client.query_inventory(material_code) @mcp.tool() async def create_purchase_order(data: dict) -> dict: async with write_limiter: return await erp_client.create_purchase_order(data)除了限流,还要考虑超时和重试。ERP 的写入操作有时候会超时,但实际已经成功了。如果盲目重试,可能创建出重复单据。我的做法是:写入操作带一个唯一的业务流水号,重试时先查一下这个流水号是否已存在,存在就返回已有结果,不存在才真正重试。
5.2 Agent 安全:不只是 Prompt 注入
Agent 安全是个大话题,热搜词里有“agent 安全”和“a-memguard: a proactive defense framework for llm-based agent memory”,说明大家开始关注 Agent 的记忆安全和行为安全。
接 OA、ERP 的场景下,安全风险主要有三类:
第一类是越权操作。Agent 可能被诱导去执行用户没有权限的操作。比如一个普通员工通过精心构造的 Prompt,让 Agent 去审批一个本该由经理审批的单据。防御手段是在 MCP Server 里做权限校验,每次工具调用都带上用户身份,Server 侧检查这个用户是否有权限执行这个操作。不要信任 Agent 传来的任何权限声明。
第二类是数据泄露。Agent 在查询数据时可能把敏感信息带出来。比如查询员工信息时返回了薪资字段。防御手段是在 MCP Server 里做字段级过滤,根据调用者的角色决定返回哪些字段。
第三类是 Prompt 注入。OA 里的审批意见、ERP 里的备注字段,都可能被恶意用户写入 Prompt 注入内容。当 Agent 读取这些内容时,可能被操控。防御手段是在把外部数据喂给模型之前,做一层清洗和转义,把类似“忽略之前的指令”这种模式识别出来并处理掉。
实操心得:我在 MCP Server 里加了一个
sanitize_external_text函数,对所有来自 OA/ERP 的文本字段做清洗。它会移除或转义一些危险模式,比如系统指令关键词、特殊分隔符等。这个函数不复杂,但能挡掉大部分低级注入。
5.3 可观测性:出问题时怎么查
Agent 上生产后,最怕的是“它为什么这么干”。用户说“我让它查库存,它给我创建了个订单”,你得能查出来是哪一步出了问题。
我的做法是在三个地方打日志:
MCP Server 侧:记录每次工具调用的入参、出参、耗时、调用者身份。这是最关键的日志,因为工具调用是 Agent 和外部系统交互的唯一通道。
Agent 编排侧:记录每轮模型调用的输入 Prompt、输出内容、选择的工具。这个日志量大,一般只保留最近 N 轮,或者采样记录。
业务系统侧:OA/ERP 自己的日志。这个你控制不了,但出问题时可以对照时间戳去查。
import logging import time logger = logging.getLogger("mcp_server") def log_tool_call(tool_name: str, user_id: str, params: dict): def decorator(func): async def wrapper(*args, **kwargs): start = time.time() try: result = await func(*args, **kwargs) logger.info( f"tool_call tool={tool_name} user={user_id} " f"params={params} duration={time.time()-start:.2f}s " f"status=success" ) return result except Exception as e: logger.error( f"tool_call tool={tool_name} user={user_id} " f"params={params} duration={time.time()-start:.2f}s " f"status=error error={str(e)}" ) raise return wrapper return decorator有了这些日志,排查问题时可以按用户 ID 或者时间范围过滤,很快就能定位到是哪次工具调用出了问题。
6. 常见问题与排查技巧实录
6.1 工具调用失败的高频原因速查表
| 现象 | 可能原因 | 排查方法 | 解决 |
|---|---|---|---|
| Agent 说“没有权限” | 鉴权过期或身份未传递 | 查 MCP Server 日志中的鉴权信息 | 检查 session 续期逻辑 |
| 工具返回空结果 | 字段映射错误或查询条件不对 | 对比原始接口返回和映射后结果 | 修正映射表 |
| 写入操作报“必填字段缺失” | 联动字段未预填 | 检查 prepare 函数是否覆盖所有联动 | 补充联动规则 |
| 调用超时 | 后端系统慢或并发太高 | 查耗时日志和限流日志 | 调整限流或加缓存 |
| Agent 选错工具 | 工具描述不清晰 | 看 Agent 的思考日志 | 优化工具描述和示例 |
| 重复创建单据 | 重试逻辑没有幂等保护 | 查是否有重复流水号 | 加幂等校验 |
6.2 泛微 OA 连接被阻止的完整解决路径
“泛微 OA 添加外部地址作为目录报错连接被阻止,因为它是由公共页面启动的,意图连接到”这个错误,我遇到过好几次。完整的解决路径是:
第一步,确认 Agent 界面的域名和端口。第二步,登录泛微管理后台,找到“系统管理-安全设置-可信站点”。第三步,把 Agent 的域名加进去,注意要带协议和端口,比如https://agent.example.com:8443。第四步,如果泛微版本较老,可能还需要改ecology/WEB-INF/prop/weaver.properties里的security.trusted.domains配置。第五步,重启泛微服务。
如果加了可信站点还是不行,检查一下是不是 HTTPS 证书问题。泛微对自签名证书比较敏感,Agent 侧最好用受信任的证书。
6.3 通达 OA MySQL 服务启动失败的排查
“通达 OA 无法启动 MySQL5-OA 服务”这个问题的原因比较多,我按概率从高到低列一下:
端口冲突是最常见的。通达 OA 自带的 MySQL 默认用 3306,如果机器上已经装了 MySQL 或者别的服务占了 3306,就起不来。解决办法是改通达 MySQL 的端口,在mysql/my.ini里改port参数。
数据目录权限问题排第二。通达 OA 的 MySQL 数据目录如果权限不对,MySQL 会拒绝启动。Windows 下检查数据目录的 NTFS 权限,Linux 下检查 owner 和 mode。
InnoDB 日志损坏排第三。如果之前非正常关机,InnoDB 的 redo log 可能损坏。可以在my.ini里临时加innodb_force_recovery=1启动,导出数据后重建。
磁盘满排第四。这个最容易被忽略,但确实会发生。检查一下数据盘剩余空间。
6.4 Agent 选错工具的优化技巧
Agent 选错工具,九成是工具描述的问题。我总结了几条优化原则:
描述里写清楚“什么时候用”和“什么时候不用”。比如“查询库存”工具的描述里,除了写“查询指定物料的库存数量”,还要写“当用户询问库存、可用量、在途量时使用此工具;当用户询问价格、供应商时不要使用此工具”。
参数描述里给示例。Agent 对示例的理解比抽象描述好。比如process_id的描述写成“审批流程实例 ID,例如 FLOW_20240115_001”。
工具数量控制在 20 个以内。工具太多,Agent 的选择准确率会下降。如果确实有很多工具,可以分组,用两级选择:先选组,再选具体工具。
定期用测试集验证。我维护了一个包含 50 个典型用户请求的测试集,每次改完工具描述就跑一遍,看准确率有没有下降。
6.5 写入操作的幂等设计
ERP 的写入操作必须做幂等,否则重试会产生重复单据。我的做法是:
在 MCP Server 侧,每个写入工具都要求传入一个request_id,这个 ID 由 Agent 生成(可以用 UUID)。Server 收到请求后,先查request_id是否已经处理过。如果处理过,直接返回上次的结果;如果没有,执行写入并记录request_id和结果。
async def create_order_idempotent(request_id: str, data: dict): # 查是否已处理 existing = await cache.get(f"order_req:{request_id}") if existing: return existing # 未处理,执行写入 result = await erp_client.create_order(data) # 记录结果,设置较长的过期时间 await cache.set(f"order_req:{request_id}", result, ex=86400) return result这个request_id的生成和传递要在 Agent 的 Prompt 里说清楚,让模型知道每次写入都要带一个新的 ID,重试时要带同一个 ID。
7. 一些收尾的实操体会
这套东西我从零搭到上生产,前后花了大概三个月。最大的体会是:Agent 接企业系统,技术难点不在 AI 侧,在集成侧。模型的能力已经足够支撑大部分场景,真正花时间的是搞清楚 OA 的审批流怎么走、ERP 的字段怎么联动、鉴权怎么续期、并发怎么控制。
FDE 这个角色在这类项目里价值很大,因为他既懂技术又懂业务,能站在中间把两边翻译清楚。如果你在做类似的项目,我的建议是:前期花足够的时间去摸清楚目标系统的脾气,不要急着写代码。把鉴权、字段映射、联动规则、并发限制这些“脏活”先理清楚,后面的 Agent 编排反而简单。
MCP 这个协议目前还在快速演进,不同版本的实现有差异。选 MCP Server 框架的时候,优先选社区活跃、更新频繁的。我用的过程中遇到过协议版本不兼容的问题,后来统一了版本就好了。
最后分享一个小技巧:在 MCP Server 里加一个health_check工具,返回各个后端系统的连通状态和 session 有效期。Agent 在开始复杂任务之前可以先调一下这个工具,如果发现某个系统不可用,可以提前告诉用户,而不是执行到一半才报错。这个工具实现简单,但用户体验提升很明显。