一、我们要做成什么

在钉钉群里发一句:

@群里的钉钉机器人 创建会议 今天下午一点 邀请某同事 主题项目例会

效果:机器人真的调用钉钉日历,建出一场 13:00–14:00 的会议,参会人包含“某同事”,标题是“项目例会”。

这不是演示话术,是真实落地的闭环。下面把搭建过程拆成 8 步。

二、整体架构(先看清链路)

钉钉群消息
   │  (钉钉连接器把消息接入 OpenClaw)
   ▼
OpenClaw Agent  ── model: react-local/react-gemma4
   │  baseUrl: http://react-proxy:11888/v1
   ▼
react-proxy(Python 增强层,本文主角)
   │  ① 强制 ReAct 兜底  ② enrich 业务增强
   ▼  upstream: http://ollama:11434/v1
本地大模型(一张消费级显卡,GPU 推理)
   │  调工具
   ▼
钉钉日历 MCP → 真实创建日历会议

一个关键点先说在前:react-proxy 不是“第二个模型”,它和本地模型跑在同一张显卡上,只是本地模型前面套的一层 Python 壳。它干两件小模型自己干不稳的事:

  1. 强制调工具兜底:小模型容易“只回话不调工具”(假成功),代理发现没调工具就重问(最多 2 轮)。
  2. 业务增强(enrich):把正文里的同事姓名映射成钉钉 userId 注入参会人;规范标题(给了主题用你的,没给用「{发起人}的会议」)。

三、准备工作

开始前确认你已经有这些(平台侧前置,不在本文代码范围内):

  • 一台能跑 Docker 的机器,带一张消费级显卡(用于本地模型 GPU 推理)。
  • 已安装并运行 ollama,且拉好了你的本地模型(本文以某个 12B 级模型举例)。
  • 一个钉钉开放平台应用(拿到 clientId / clientSecret),并开通了日历相关权限。
  • 一个钉钉日历 MCP 的服务地址(形如 <你的钉钉日历 MCP 网关地址>,需带你的访问凭证)。

四、实操步骤

步骤 1:准备模型服务(ollama)

确保 ollama 在容器内网可达。我们把 OpenClaw 系列容器都挂到同一个自定义网络 ai-net 上,容器间用服务名互访。

# 创建专用网络(只需一次)
docker network create ai-net
启动 ollama(按你本机实际情况,注意把显卡透传给容器)
docker run -d --name ollama --network ai-net --gpus all 
-v <宿主机模型目录>:/root/.ollama
ollama/ollama

验证模型能跑:

docker exec ollama ollama list
# 确认你要用的那个模型已经在列表里

步骤 2:启动 OpenClaw 核心

OpenClaw 用 Docker 跑,配置文件 openclaw.json 通过卷挂进容器。关键点是:模型的 baseUrl 不要直连 ollama,而是指向我们要加的 react-proxy:11888

docker run -d --name openclaw --network ai-net --restart unless-stopped \
  -p 18789:18789 \
  -v D:/Docker/openclaw-data/openclaw.json:/home/node/.openclaw/openclaw.json \
  ghcr.io/openclaw/openclaw:latest

openclaw.json 里和模型相关的核心片段(替换成你自己的):

{
  "agents": {
    "defaults": {
      "model": "react-local/react-gemma4",
      "compaction": {
        "reserveTokensFloor": 4096,
        "reserveTokens": 4096
      }
    }
  },
  "models": {
    "react-gemma4": {
      "contextWindow": 32768,
      "maxTokens": 2048,
      "baseUrl": "http://react-proxy:11888/v1",
      "model": "gemma4:12b"
    }
  },
  "mcp": {
    "servers": {
      "dingtalk-calendar": {
        "url": "<你的钉钉日历 MCP 网关地址>"
      }
    }
  },
  "channels": {
    "dingtalk": {
      "enabled": true,
      "clientId": "<你的钉钉 clientId>",
      "clientSecret": "<你的钉钉 clientSecret>",
      "groupPolicy": "open"
    }
  }
}

注意 contextWindowcompaction.reserveTokens* 的关系:预留必须远小于窗口,否则长会话会触发压缩死循环(踩坑 5 详述)。

步骤 3:写“增强代理”react-proxy

新建 react_proxy.py,它做两件事:转发到 ollama + 在模型返回建会工具调用时做 enrich。下面是核心逻辑骨架(完整脚本里还包含强制调工具的重问循环,思路一致):

import json, os, httpx
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://ollama:11434/v1")
CONTACTS = json.load(open("/contacts.json", encoding="utf-8")).get("contacts", {})
INITIATOR = os.getenv("INITIATOR_NAME", "我")  # 默认标题里的"发起人"
def enrich_create_event(tool_calls, messages):
"""服务端兜底:把正文里的同事姓名映射成 userId 注入参会人,并规范标题。"""
topic = extract_user_topic(messages)  # 若用户给了主题则优先用
for tc in tool_calls:
fn = tc.get("function", {})
if "create_calendar_event" not in fn.get("name", ""):
continue
args = json.loads(fn.get("arguments", "{}"))
# 1) 参会人:姓名 -> userId
att = [str(a) for a in (args.get("attendees") or [])]
norm = []
for a in att:
uid = CONTACTS.get(a)  # 已知姓名直接映射
norm.append(uid if uid else a)
# 2) 标题:给了主题用主题,没给且是空壳就用「{发起人}的会议」
s = (args.get("summary") or "").strip()
if topic:
args["summary"] = topic
elif is_shell_title(s):
args["summary"] = f"{INITIATOR}的会议"
args["attendees"] = norm
fn["arguments"] = json.dumps(args, ensure_ascii=False)
return tool_calls

为什么把“姓名→userId”“标题规范”写成代码而不是塞进提示词?因为这类“必须做对”的强逻辑,代码比 system prompt 稳十倍。

步骤 4:启动 react-proxy 容器并挂载

--env-file 传中文环境变量(避免命令行中文编码坑),把脚本和通讯录挂进容器:

# react-proxy.env 内容:
# INITIATOR_NAME=<你的名字,用作默认标题的发起人>
# OLLAMA_URL=http://ollama:11434/v1
# MAX_RETRIES=2
docker run -d --name react-proxy --network ai-net --restart unless-stopped 
--env-file D:/Docker/openclaw-scripts/react-proxy.env
-v D:/Docker/openclaw-scripts/react_proxy.py:/react_proxy.py
-v D:/Docker/openclaw-scripts/contacts.json:/contacts.json
ghcr.io/openclaw/openclaw:latest
python3 /react_proxy.py

验证链路通了(从 openclaw 容器内访问代理,应返回你的模型名):

docker exec openclaw curl -s http://react-proxy:11888/v1/models
# 期望看到你的模型名,例如 gemma4:12b

步骤 5:登记通讯录 contacts.json

钉钉建会必须传 userId(不是姓名),所以维护一张「姓名→userId」静态表。新增常邀成员,加一行、重启代理即可。

{
  "contacts": {
    "某同事": "<钉钉 userId,一串数字>",
    "另一同事": "<钉钉 userId,一串数字>"
  }
}

怎么拿到同事的 userId?用钉钉通讯录 MCP 的“按姓名查用户”工具即可,不在本文展开。

步骤 6:把规则写进技能 SKILL.md

让 Agent 建会时自动遵守约定,新建一个技能文件 /home/node/.openclaw/skills/dingtalk-meeting/SKILL.md,核心要点:

# 钉钉会议创建技能
- 参会人必须用 userId;已知同事查 contacts.json 映射。
- 标题规则:用户给了主题用用户的;没给则用「{发起人}的会议」。
- 群消息里 @成员 不会被自动带入,必须在正文里写清"邀请某某"。
- 时间要带"今天/明天 + 几点",默认按本地时区。

把文件 docker cp 进容器对应目录,OpenClaw 重启后即生效。

步骤 7:验收——在群里发一句话

等 OpenClaw healthy(首次启动约 6–7 分钟,期间网页“gateway 断开”属正常),在钉钉群发:

@群里的钉钉机器人 创建会议 今天下午一点 邀请某同事 主题项目例会

验收标准:

  • 日历里出现 13:00–14:00 的会议 ✅
  • 参会人含“某同事” ✅
  • 标题是“项目例会”(你给的主题被保留)✅

五、踩坑速查(附解法)

现象解法
假成功机器人说建好了,日历没有加 react-proxy 强制调工具兜底,没调工具就重问
参会人只有自己邀请了却没带进去contacts.json 登记 userId,enrich 自动注入
标题乱填变成“会议(邀请:XX)”enrich 识别空壳标题,纠正为「{发起人}的会议」
群 @ 失效@同事没被邀请连接器不透传 @提及,参会人写在正文里驱动注入
代理不回复改小窗口后彻底卡死contextWindow 留足,reserveTokens 远小于窗口

关于“群 @ 失效”重点说一句:我们拉了会话原始日志 .trajectory.jsonl 实锤——钉钉连接器注入给 Agent 的上下文只有发送者,没有 @成员。所以“@能不能被 Agent 感知”是连接器级别的能力,不是 MCP 或模型的事。钉钉日历 MCP 的 attendees 参数确实支持 @写法,但前提是 @数据先到 Agent。上游丢了,下游再强也救不回。当前用“正文写姓名”规避即可。

六、给同行的建议

  1. 小模型落地企业工具,务必加“强制调工具”兜底。模型越小,函数调用越不可靠,“假成功”比报错更坑。
  2. 业务强逻辑下沉到增强层,别指望提示词。userId 映射、标题规范写成代码最稳。
  3. 选型连接器时,确认它是否透传 @提及 / atUsers。这是群协作的隐形前提,文档往往不写,要实测入站消息原文。
  4. 上下文窗口留足余量。Agent 多轮对话 token 累积很容易超你预期。
  5. 日志是第一真相源。会话 .trajectory.jsonldocker logs 权威,能直接看到 Agent 收到的原始消息。

七、结语

“自然语言建会”看似一个功能点,实则串联起模型可靠性、企业身份体系(userId)、连接器能力边界、上下文工程四条主线。难点不在“调一个 MCP”,而在把这些异构环节缝成一个稳的闭环。照着上面 8 步搭一遍,你也能在群里用一句话把会开了。

(文中架构与配置均来自真实生产环境复盘,具体鉴权密钥、真实姓名与用户 ID 均已脱敏。)

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐