[实践]-本地大模型 + MCP + Agent 对接 SAP OData API 实现Web Chart APP

MYchart AI 助手:本地大模型 + MCP + Agent 对接 SAP OData API 的完整实践

从零搭建一个完全本地化的智能助手:用 Qwen 本地大模型做大脑,用 MCP 协议把 SAP OData 接口封装成工具,让 Agent 自主决策、调用工具、查询 SAP 真实数据,最终在 Web UI 上实现Chart App,呈现答案与调用轨迹。


一、背景与目标

企业内部有大量数据沉淀在 SAP 等 ERP 系统中,业务人员获取数据往往需要:

  1. 记住复杂的表名(如 T001 公司代码表、SKB1 总账科目表);
  2. 会写 OData 接口调用;
  3. 从 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")

两个关键踩坑点

  1. stdio vs SSEpython3 mcp_server.py 默认是 stdio 模式 ,进程不监听端口。要连接"已启动的网络服务",必须用 mcp.run(transport="sse", port=8001)
  2. 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 工具)」模式,输入:

读取公司主数据表的数据,告诉我一共有多少行,第一条记录是什么

助手自动完成:

  1. 模型决策 → 调用 read_table_data(table_name="T001")
  2. MCP Server 调 SAP OData API → 返回 20 行公司代码数据
  3. 模型基于真实数据总结 → "总记录数 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

在AI+时代,一切工程都值得用AI来重构一遍!

相关推荐
酒旅Agent开发实战1 小时前
开发者如何选择API和MCP
人工智能·大模型·酒店预订·ai agent·mcp
龙骑士baby2 小时前
重建 AI 认知第 6 篇:RAG——答案不在"检索 + 生成"这四个字里
ai·llm·rag
王红臣同学2 小时前
Microduck 强化学习源码拆解:一只 800g 的机器鸭怎么学会走路
人工智能·机器学习·ai
俊哥V2 小时前
AI 今日研究简报 · 2026-09-11
人工智能·ai
xrlfreedom2 小时前
大厂 MCP 面试实录:可复用 Prompts 工作流服务的架构设计与落地
prompts·mcp·oauth 2.1·超时、重试与幂等
loser.with.m3 小时前
【AgentScope 2.0】05-从数据库配置到可运行 Agent:十个 Builder 开关怎么把一行 JSON 变成 HarnessAgent
人工智能·spring boot·ai
ChampaignWolf3 小时前
YAAI 生态一周年:把 ABAP 变成 Agent 工具栈的开源组合拳
开源·abap·mcp·开源ai·yaai
王红臣同学3 小时前
在 Windows 上复现 Microduck 机器鸭仿真
人工智能·ai
DO_Community4 小时前
Omarchy 将研发基础设施迁移至 DigitalOcean 云平台
linux·人工智能·llm·agent·omarchy