MYchat AI 助手本地大模型 MCP Agent 对接 SAP OData API 的完整实践从零搭建一个完全本地化的智能助手用 Qwen 本地大模型做大脑用 MCP 协议把 SAP OData 接口封装成工具让 Agent 自主决策、调用工具、查询 SAP 真实数据最终在 Web UI 上实现Chat App呈现答案与调用轨迹。一、背景与目标企业内部有大量数据沉淀在 SAP 等 ERP 系统中业务人员获取数据往往需要记住复杂的表名如T001公司代码表、SKB1总账科目表会写 OData 接口调用从 JSON 中手工提取字段。我们希望做一个AI 助手让用户用自然语言提问助手自动判断是否需要查 SAP自主决定调用哪个工具、传什么参数拿到 SAP 真实返回后用自然语言总结给用户。关键约束数据不出内网、模型本地部署、不依赖云端大模型。关键词本地大模型 · MCP · Agent · SAP · OData API · Qwen-Agent · LLM · Chat 助手二、整体架构┌──────────────────────────────────────────────────────────────┐ │ MYchart AI 助手 (Flask Web) │ │ ┌──────────┐ ┌──────────────────────────────────────┐ │ │ │ 前端 UI │──▶│ LLMClient (OpenAI 兼容 SDK) │ │ │ │(index.html│ │ ├─ chat() 普通对话 │ │ │ │ app.js) │ │ └─ chat_with_tools() Agent 工具链 │ │ │ └──────────┘ └──────────┬───────────────────────────┘ │ └──────────────────────────────┼───────────────────────────────┘ │ OpenAI 兼容接口 ▼ ┌──────────────────────────┐ │ 本地大模型推理服务 │ │ oMLX (Apple Silicon) │ │ Qwen3.6-35B-A3B-4bit │ │ http://localhost:8000 │ └──────────────────────────┘ ┌──────────────────────────────────────────────────────────────┐ │ MCP 工具层 (Model Context Protocol) │ │ ┌──────────────────────┐ stdio ┌───────────────────┐ │ │ │ MCPStdioClient │◀──────────▶│ FastMCP Server │ │ │ │ (后台事件循环同步封装)│ │ mcp_server.py │ │ │ └──────────────────────┘ │ ├ read_table_data│ │ │ │ └ read_table_info│ │ │ └────────┬──────────┘ │ │ │ requests │ │ ▼ │ │ ┌──────────────────┐ │ │ │ SAP OData API │ │ │ │ /DB_DATASet(..)│ │ │ │ /DB_INFOSet(..)│ │ │ └──────────────────┘ │ └──────────────────────────────────────────────────────────────┘核心思想LLM 不直接写 SQL/调接口而是通过 MCP 协议调用工具。工具是已注册的函数LLM 只负责决定调用谁、传什么参。三、技术栈选型层级技术说明本地大模型oMLX Qwen3.6-35B-A3BApple Silicon 原生优化OpenAI 兼容 API (http://localhost:8000/v1)MCP 协议fastmcp(服务端) mcp(客户端)官方 SDKstdio 传输Agent 框架自实现LLMClient.chat_with_tools()参考 Qwen-Agent 的工具调用闭环轻量、可控、不引入重框架SAP 接口requests OData v2直接调用 SAP Gateway REST 接口Web 框架Flask同步、轻量前端原生 JS 少量 CSS无需构建工具为什么选oMLX而非 OllamaoMLX 针对 Apple Silicon 做了分层 KV 缓存、连续批处理多模型热切换时响应更快并且同样暴露 OpenAI 兼容接口零侵入替换。四、关键实现4.1 本地大模型oMLX 提供 OpenAI 兼容 API# 启动 oMLX模型目录放 MLX 格式模型omlx serve --model-dir ~/OMLX启动后http://localhost:8000/v1即可用 OpenAI SDK 直接调用fromopenaiimportOpenAI clientOpenAI(base_urlhttp://localhost:8000/v1,api_keyomlx)respclient.chat.completions.create(modelmlx-community/Qwen3.6-35B-A3B-4bit,messages[{role:user,content:你好}],)print(resp.choices[0].message.content)4.2 SAP OData API 封装SAP OData v2 的关键语法字符串主键必须用单引号EntitySet(键值)双引号是非法语法。# sap_service.pyimportrequestsfromrequests.authimportHTTPBasicAuth BASE_URLhttp://192.168.31.21:8080/sap/opu/odata/sap/YSAP_SERVICE_SRV/sessionrequests.Session()session.authHTTPBasicAuth(GONGJH,12qwaszx)defget_table_data(table_name):urlf{BASE_URL}DB_DATASet({table_name})?$formatjsonrespsession.get(url,timeout60)resp.raise_for_status()returnresp.json()[d]# SAP OData v2 返回 {d: {...}}4.3 MCP Server把 SAP 接口包装成工具用fastmcp把 SAP 查询函数暴露为 MCP 工具# mcp_server.pyimportjsonfromfastmcpimportFastMCPfromsap_serviceimportget_table_data,get_table_info mcpFastMCP(SAPService)mcp.tool()defread_table_data(table_name:str)-str:读取 SAP 表的数据。传入表名如 T001返回该表数据的 JSON 文本。returnjson.dumps(get_table_data(table_name),ensure_asciiFalse,defaultstr)mcp.tool()defread_table_info(table_name:str)-str:读取 SAP 表的结构信息字段定义。returnjson.dumps(get_table_info(table_name),ensure_asciiFalse,defaultstr)if__name____main__:mcp.run()# 默认 stdio 传输由客户端拉起子进程⚠️踩坑点工具声明- str就必须返回字符串。FastMCP 2.x 会做严格的输出校验get_table_data返回 dict 会直接被拒绝Output validation error。4.4 MCP Client同步封装 后台事件循环官方mcpSDK 是 async API而 Flask 是同步框架。我们在后台守护线程里跑一个独立的 asyncio 事件循环把 stdio 连接、工具发现、工具调用全部包装成同步方法# mcp_client.py精简版importasyncio,threadingfrommcpimportClientSession,StdioServerParametersfrommcp.client.stdioimportstdio_clientclassMCPStdioClient:def__init__(self,command,args,cwdNone):self._loopasyncio.new_event_loop()self._threadthreading.Thread(targetself._run_loop,daemonTrue)self._lockthreading.RLock()self._paramsStdioServerParameters(commandcommand,argsargs,cwdcwd)def_run_loop(self):asyncio.set_event_loop(self._loop)self._loop.run_forever()defstart(self):withself._lock:ifnotself._thread.is_alive():self._thread.start()asyncio.run_coroutine_threadsafe(self._lifespan(),self._loop)# 用 threading.Event 等待握手完成...propertydefopenai_tools(self):MCP 工具定义 → OpenAI function-calling 格式return[{type:function,function:{name:t[name],description:t[description],parameters:t[inputSchema]}}fortinself.list_mcp_tools()]defcall_tool(self,name,argumentsNone)-str:resultasyncio.run_coroutine_threadsafe(self._session.call_tool(name,argumentsor{}),self._loop).result(timeout120)return\n.join(b.textforbinresult.contentifb.typetext)两个关键踩坑点stdio vs SSEpython3 mcp_server.py默认是stdio 模式进程不监听端口。要连接已启动的网络服务必须用mcp.run(transportsse, port8001)。anyio cancel scope 跨任务stdio 上下文的进入和退出必须在同一个协程任务内完成否则会抛Attempted to exit cancel scope in a different task。解决用一个长生命周期协程_lifespan()包住整个连接通过asyncio.Event控制关闭。4.5 Agent 工具调用闭环这是核心LLM 返回tool_calls→ 执行 MCP 工具 → 把结果以roletool回填 → 继续下一轮 → 模型不再调工具时输出最终回答。# llm_client.py精简版defchat_with_tools(self,message,mcp_client,modelNone,max_rounds5):toolsmcp_client.openai_tools messages[{role:system,content:SYSTEM_PROMPT},{role:user,content:message}]trace[]for_inrange(max_rounds):respself._client.chat.completions.create(modelmodel,messagesmessages,toolstools,tool_choiceauto)msgresp.choices[0].messageifnotmsg.tool_calls:# 无工具调用 → 最终回答return{reply:(msg.contentor).strip(),tool_calls:trace}# 回填助手的 tool_callsmessages.append({role:assistant,content:msg.contentor,tool_calls:[{id:tc.id,type:function,function:{name:tc.function.name,arguments:tc.function.arguments}}fortcinmsg.tool_calls]})# 逐个执行 MCP 工具并回填fortcinmsg.tool_calls:argsjson.loads(tc.function.argumentsor{})resultmcp_client.call_tool(tc.function.name,args)trace.append({name:tc.function.name,arguments:args,result:result})messages.append({role:tool,tool_call_id:tc.id,content:result})# 达到最大轮数仍在调用工具 → 去掉 tools 强制收尾...这个闭环和 Qwen-Agent 的Assistant.run()本质相同只是我们用更轻量的方式实现方便与 Flask 集成。4.6 Flask 路由 前端 UI后端新增两个端点app.post(/api/agent)# Agent 模式走工具调用闭环app.get(/api/mcp/tools)# 查看已注册工具懒启动 MCP 连接前端在模式下拉框增加「AgentSAP 工具」选择后自动走/api/agent并把工具调用轨迹渲染成可折叠的卡片// app.jsconstendpointmodeagent?/api/agent:(modestructured?/api/structured:/api/chat);UI 展示效果 工具调用共 1 次 #1 read_table_data ✅ 成功 参数 table_nameT001 工具返回点击展开/收起 根据读取的 T001 表公司代码主数据数据 1. 总记录数20 行 2. 第一条记录的公司代码0001五、Qwen-Agent 方式备选方案如果不想自己实现工具调用闭环也可以直接用Qwen-Agent框架它内置了 MCP 工具加载和多轮调度# agent_test.pyfromqwen_agent.agentsimportAssistant llm_cfg{model:Qwen3.6-35B-A3B-4bit,model_server:http://localhost:8000/v1,api_key:sk-XXXX,}# MCP 配置stdio 方式自动拉起 mcp_server.pytools[{mcpServers:{SAPServer:{command:/path/to/.venv/bin/python3,args:[/path/to/mcp_server.py],}}}]botAssistant(llmllm_cfg,function_listtools)forround_msgsinbot.run(messages[{role:user,content:读取T001表的数据}]):...# 每轮包含 tool_calls / tool / assistant 消息⚠️ 注意command必须是装有 fastmcp 的解释器不能用系统 Python。我们项目里就是项目自带.venv/bin/python3。两种方案对比自实现chat_with_tools()Qwen-Agent代码量约 100 行几乎零框架内置可控性高可自定义回填、日志、错误处理中框架封装与 Flask 集成直接同步调用需要处理异步/流式适用场景嵌入现有 Web 服务快速原型 / CLI 对话MYchat AI 助手选择了自实现因为要嵌入 Flask Web 服务对同步性和可控性要求高。六、踩坑与经验总结6.1 mcp SDK 版本兼容qwen_agent0.0.34 使用旧版函数名streamablehttp_client而mcp2.0已改名为streamable_http_client直接升级会报ImportError。需要锁定mcp1.11.0或使用自实现客户端绕过这个依赖。6.2 stdio 服务不监听端口很多人以为python3 mcp_server.py启动了一个 HTTP 服务其实它是stdio 进程由客户端拉起并通过 stdin/stdout 通信。要做网络服务必须显式指定 transport。6.3 工具输出类型要匹配声明FastMCP 2.x 对工具返回值做 Pydantic 校验def foo() - str就必须返回 str。dict 要先json.dumps否则 LLM 拿到的是校验失败错误而不是业务数据。6.4 anyio cancel scope 必须同任务进入/退出MCP 的 stdio 客户端底层用 anyio上下文管理器的__aenter__和__aexit__必须在同一个协程任务内执行。不能在一个协程里__aenter__在另一个协程里__aexit__。6.5 SAP OData 主键用单引号OData v2 规范字符串主键必须用单引号EntitySet(T001)双引号返回 400。另外$formatjson比依赖 Accept 头更可靠。七、效果演示在 Web UI 中选择「AgentSAP 工具」模式输入读取公司主数据表的数据告诉我一共有多少行第一条记录是什么助手自动完成模型决策 → 调用read_table_data(table_nameT001)MCP Server 调 SAP OData API → 返回 20 行公司代码数据模型基于真实数据总结 → “总记录数 20 行第一条公司代码 0001公司名称 SAP A.G.”实际测试结果如下查看运行日志如下flask日志mcp server日志整个过程数据不出内网模型本地运行工具调用轨迹透明可见。八、总结这套架构的核心价值在于解耦LLM 只负责决策调哪个工具、传什么参MCP 工具负责执行封装好的函数有明确的输入输出 schemaAgent 框架负责编排多轮工具调用、结果回填、最终汇总。这让企业可以逐步把内部系统SAP、数据库、邮件、工单等封装成 MCP 工具本地大模型就能像企业助理一样用自然语言帮员工查数据、写报告、执行操作而所有数据都留在企业内网。附项目结构mychat_ai/ ├── app.py # Flask 入口/api/chat /api/agent /api/mcp/tools ├── llm_client.py # LLMClient chat_with_tools() 工具调用闭环 ├── mcp_client.py # MCPStdioClient后台事件循环 stdio 同步封装 ├── mcp_server.py # FastMCP Serverread_table_data / read_table_info ├── sap_service.py # SAP OData v2 访问封装 ├── config.py # 配置含 MCP 相关参数 ├── templates/index.html ├── static/js/app.js # Agent 模式 UI 工具轨迹渲染 ├── static/css/style.css └── .env.example # 配置示例运行.venv/bin/python3 app.py# 浏览器打开 http://127.0.0.1:5001在AI时代一切工程都值得用AI来重构一遍
