MYchart AI 助手:本地大模型 + MCP + Agent 对接 SAP OData API 的完整实践
从零搭建一个完全本地化的智能助手:用 Qwen 本地大模型做大脑,用 MCP 协议把 SAP OData 接口封装成工具,让 Agent 自主决策、调用工具、查询 SAP 真实数据,最终在 Web UI 上实现Chart App,呈现答案与调用轨迹。
一、背景与目标
企业内部有大量数据沉淀在 SAP 等 ERP 系统中,业务人员获取数据往往需要:
- 记住复杂的表名(如
T001公司代码表、SKB1总账科目表); - 会写 OData 接口调用;
- 从 JSON 中手工提取字段。
我们希望做一个"AI 助手",让用户用自然语言提问,助手自动:
- 判断是否需要查 SAP;
- 自主决定调用哪个工具、传什么参数;
- 拿到 SAP 真实返回后,用自然语言总结给用户。
关键约束:数据不出内网、模型本地部署、不依赖云端大模型。
关键词:本地大模型 · MCP · Agent · SAP · OData API · Qwen-Agent · LLM · Chart 助手
二、整体架构
┌──────────────────────────────────────────────────────────────┐
│ 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-A3B-4bit | Apple Silicon 原生优化,OpenAI 兼容 API (http://localhost:8000/v1) |
| MCP 协议 | fastmcp (服务端) + mcp (客户端) |
官方 SDK,stdio 传输 |
| Agent 框架 | 自实现 LLMClient.chat_with_tools()(参考 Qwen-Agent 的工具调用闭环) |
轻量、可控、不引入重框架 |
| SAP 接口 | requests + OData v2 |
直接调用 SAP Gateway REST 接口 |
| Web 框架 | Flask | 同步、轻量 |
| 前端 | 原生 JS + 少量 CSS | 无需构建工具 |
为什么选 oMLX 而非 Ollama?oMLX 针对 Apple Silicon 做了分层 KV 缓存、连续批处理,多模型热切换时响应更快;并且同样暴露 OpenAI 兼容接口,零侵入替换。
四、关键实现
4.1 本地大模型:oMLX 提供 OpenAI 兼容 API
bash
# 启动 oMLX(模型目录放 MLX 格式模型)
omlx serve --model-dir ~/OMLX
启动后 http://localhost:8000/v1 即可用 OpenAI SDK 直接调用:
python
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="omlx")
resp = client.chat.completions.create(
model="mlx-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('键值'),双引号是非法语法。
python
# sap_service.py
import requests
from requests.auth import HTTPBasicAuth
BASE_URL = "http://192.168.31.21:8080/sap/opu/odata/sap/YSAP_SERVICE_SRV/"
session = requests.Session()
session.auth = HTTPBasicAuth("GONGJH", "12qwaszx")
def get_table_data(table_name):
url = f"{BASE_URL}DB_DATASet('{table_name}')?$format=json"
resp = session.get(url, timeout=60)
resp.raise_for_status()
return resp.json()["d"] # SAP OData v2 返回 {"d": {...}}
4.3 MCP Server:把 SAP 接口包装成工具
用 fastmcp 把 SAP 查询函数暴露为 MCP 工具:
python
# mcp_server.py
import json
from fastmcp import FastMCP
from sap_service import get_table_data, get_table_info
mcp = FastMCP("SAPService")
@mcp.tool()
def read_table_data(table_name: str) -> str:
"""读取 SAP 表的数据。传入表名(如 T001),返回该表数据的 JSON 文本。"""
return json.dumps(get_table_data(table_name), ensure_ascii=False, default=str)
@mcp.tool()
def read_table_info(table_name: str) -> str:
"""读取 SAP 表的结构信息(字段定义)。"""
return json.dumps(get_table_info(table_name), ensure_ascii=False, default=str)
if __name__ == "__main__":
mcp.run() # 默认 stdio 传输,由客户端拉起子进程
⚠️ 踩坑点 :工具声明
-> str,就必须返回字符串。FastMCP 2.x 会做严格的输出校验,get_table_data返回 dict 会直接被拒绝(Output validation error)。
4.4 MCP Client:同步封装 + 后台事件循环
官方 mcp SDK 是 async API,而 Flask 是同步框架。我们在后台守护线程里跑一个独立的 asyncio 事件循环,把 stdio 连接、工具发现、工具调用全部包装成同步方法:
python
# mcp_client.py(精简版)
import asyncio, threading
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
class MCPStdioClient:
def __init__(self, command, args, cwd=None):
self._loop = asyncio.new_event_loop()
self._thread = threading.Thread(target=self._run_loop, daemon=True)
self._lock = threading.RLock()
self._params = StdioServerParameters(command=command, args=args, cwd=cwd)
def _run_loop(self):
asyncio.set_event_loop(self._loop)
self._loop.run_forever()
def start(self):
with self._lock:
if not self._thread.is_alive():
self._thread.start()
asyncio.run_coroutine_threadsafe(self._lifespan(), self._loop)
# 用 threading.Event 等待握手完成...
@property
def openai_tools(self):
"""MCP 工具定义 → OpenAI function-calling 格式"""
return [{
"type": "function",
"function": {"name": t["name"], "description": t["description"],
"parameters": t["inputSchema"]}
} for t in self.list_mcp_tools()]
def call_tool(self, name, arguments=None) -> str:
result = asyncio.run_coroutine_threadsafe(
self._session.call_tool(name, arguments or {}), self._loop
).result(timeout=120)
return "\n".join(b.text for b in result.content if b.type == "text")
两个关键踩坑点:
- stdio vs SSE :
python3 mcp_server.py默认是 stdio 模式 ,进程不监听端口。要连接"已启动的网络服务",必须用mcp.run(transport="sse", port=8001)。 - anyio cancel scope 跨任务 :stdio 上下文的进入和退出必须在同一个协程任务 内完成,否则会抛
Attempted to exit cancel scope in a different task。解决:用一个长生命周期协程_lifespan()包住整个连接,通过asyncio.Event控制关闭。
4.5 Agent 工具调用闭环
这是核心:LLM 返回 tool_calls → 执行 MCP 工具 → 把结果以 role=tool 回填 → 继续下一轮 → 模型不再调工具时输出最终回答。
python
# llm_client.py(精简版)
def chat_with_tools(self, message, mcp_client, model=None, max_rounds=5):
tools = mcp_client.openai_tools
messages = [{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": message}]
trace = []
for _ in range(max_rounds):
resp = self._client.chat.completions.create(
model=model, messages=messages, tools=tools, tool_choice="auto")
msg = resp.choices[0].message
if not msg.tool_calls: # 无工具调用 → 最终回答
return {"reply": (msg.content or "").strip(), "tool_calls": trace}
# 回填助手的 tool_calls
messages.append({"role": "assistant", "content": msg.content or "",
"tool_calls": [{"id": tc.id, "type": "function",
"function": {"name": tc.function.name,
"arguments": tc.function.arguments}}
for tc in msg.tool_calls]})
# 逐个执行 MCP 工具并回填
for tc in msg.tool_calls:
args = json.loads(tc.function.arguments or "{}")
result = mcp_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
后端新增两个端点:
python
@app.post("/api/agent") # Agent 模式:走工具调用闭环
@app.get("/api/mcp/tools") # 查看已注册工具(懒启动 MCP 连接)
前端在模式下拉框增加「Agent(SAP 工具)」,选择后自动走 /api/agent,并把工具调用轨迹渲染成可折叠的卡片:
javascript
// app.js
const endpoint = mode === "agent" ? "/api/agent" : (mode === "structured" ? "/api/structured" : "/api/chat");
UI 展示效果:
🔧 工具调用(共 1 次)
#1 read_table_data ✅ 成功
参数: table_name="T001"
工具返回(点击展开/收起)
根据读取的 T001 表(公司代码主数据)数据:
1. 总记录数:20 行
2. 第一条记录的公司代码:0001
五、Qwen-Agent 方式(备选方案)
如果不想自己实现工具调用闭环,也可以直接用 Qwen-Agent 框架,它内置了 MCP 工具加载和多轮调度:
python
# agent_test.py
from qwen_agent.agents import Assistant
llm_cfg = {
'model': 'Qwen3.6-35B-A3B-4bit',
'model_server': 'http://localhost:8000/v1',
'api_key': 'sk-0501',
}
# MCP 配置:stdio 方式自动拉起 mcp_server.py
tools = [{'mcpServers': {
'SAPServer': {
'command': '/path/to/.venv/bin/python3',
'args': ['/path/to/mcp_server.py'],
}
}}]
bot = Assistant(llm=llm_cfg, function_list=tools)
for round_msgs in bot.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 对话 |
我们的 MYchart AI 助手选择了自实现,因为要嵌入 Flask Web 服务,对同步性和可控性要求高。
六、踩坑与经验总结
6.1 mcp SDK 版本兼容
qwen_agent 0.0.34 使用旧版函数名 streamablehttp_client,而 mcp>=2.0 已改名为 streamable_http_client,直接升级会报 ImportError。需要锁定 mcp==1.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。另外 $format=json 比依赖 Accept 头更可靠。
七、效果演示
在 Web UI 中选择「Agent(SAP 工具)」模式,输入:
读取公司主数据表的数据,告诉我一共有多少行,第一条记录是什么
助手自动完成:
- 模型决策 → 调用
read_table_data(table_name="T001") - MCP Server 调 SAP OData API → 返回 20 行公司代码数据
- 模型基于真实数据总结 → "总记录数 20 行,第一条公司代码 0001,公司名称 SAP A.G."
- 实际测试结果如下:

查看运行日志如下: - flask日志

- mcp server日志

整个过程数据不出内网,模型本地运行,工具调用轨迹透明可见。
八、总结
这套架构的核心价值在于解耦:
- LLM 只负责"决策":调哪个工具、传什么参;
- MCP 工具负责"执行":封装好的函数,有明确的输入输出 schema;
- Agent 框架负责"编排":多轮工具调用、结果回填、最终汇总。
这让企业可以逐步把内部系统(SAP、数据库、邮件、工单等)封装成 MCP 工具,本地大模型就能像"企业助理"一样,用自然语言帮员工查数据、写报告、执行操作,而所有数据都留在企业内网。
附:项目结构
mychart_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 Server:read_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 # 配置示例
运行:
bash
.venv/bin/python3 app.py
# 浏览器打开 http://127.0.0.1:5001