storemind
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
StoreMind · 门店运营知识库 Agent
面向连锁酒吧 / 餐饮门店的 SOP 知识库问答系统。店员问「客人说酒有异味怎么办」,店长问「上周天河店卖了多少」,都能答。
不是纯 RAG 问答,两个原因:
规则和事实是分开的——规则在文档里,事实在订单库里。所以系统先做意图路由,再决定查知识库、查数据库,还是两路并发。
检索结果不一定够用——所以模型在拿到资料后要先自检,不够就改写检索词重查。
┌─ retrieve_guard ─┐
店员提问 → route ────────┤ ├─ merge → judge ─┬→ answer(带引用)
└─ data_guard ─────┘ │ ▲
│ └─ rewrite → retrieve_again
└──── 判充足两个分支同一 superstep 并行执行,merge 等两路都完成才放行。
judge → rewrite → retrieve_again → judge 这个环是本项目的 Agent 层——
图里唯一的条件边,也是唯一一处「走哪条路由模型运行时决定」的地方。
编排用 LangGraph,会话状态走 PostgreSQL checkpointer,thread_id 一给就有多轮上下文。
同一套逻辑有两套实现:agent/graph.py(LangGraph,图里的环)和 agent/pipeline.py(手写状态机,while 循环)。
scripts/compare_orchestration.py 会验证两条路径输出一致——6/6 通过。
这比「我用了 LangGraph」有说服力得多:同一个自检循环,手写版是 12 行 while,
图版是两条边,而它们的输出完全一致。
为什么做这个
多门店 SaaS 的共性痛点:运营知识散在群聊、SOP 文档、活动规则里。新人上手慢,同一个问题不同店长答得不一样。而客人问「我储值送的 100 能不能在另一家店花」这种问题,答案一半在制度文档里、一半在会员系统里——单靠文档问答答不准。
Related MCP server: look-charme-mcp
快速开始
方式一:本地 Python + 宿主机 ollama(不使用 Docker)
需要一块 GPU(8GB 显存即可)和宿主机 ollama:
# 1. 装 ollama 并起服务
scoop install ollama # Windows;macOS/Linux 用官方 install.sh
ollama serve # 默认 127.0.0.1:11434
ollama pull bge-m3 # 1.2GB,嵌入
ollama pull qwen2.5:7b # 4.7GB,生成
# 2. 装项目依赖
python -m venv .venv
.venv\Scripts\activate # Windows; macOS/Linux: source .venv/bin/activate
pip install -r requirements.txt
# 3. 准备数据
python scripts/seed_ops_db.py # 生成门店经营数据(4 店 / 60 天 / 6561 单)
python scripts/build_index.py --rebuild # 建知识库索引(bge-m3,1024 维,约 2.5 秒)
python scripts/smoke_test.py # 端到端冒烟,5 条用例
# 4. 起服务
uvicorn app.main:app --reload --port 8000打开 http://127.0.0.1:8000/ 是可以直接对话的页面。或者 http://127.0.0.1:8000/docs 看接口。
当前默认部署是下面的 Docker(模型也在容器里);宿主机 ollama 计划任务已禁用。 要走这条本地路径,需要手动起
ollama serve,并把.env的SM_OLLAMA_BASE改回 11434。
方式二:本地 Python(复用 Docker 里的 PostgreSQL 和 ollama)
运行时只有 PostgreSQL 一个存储后端。先用 compose 起数据库和模型(不启动 app/mcp,避免占 8000):
docker compose -f deploy/docker-compose.yml up -d postgres ollama ollama-import ollama-pull
pip install -r requirements.txt
python scripts/seed_ops_db.py
python scripts/build_index.py --rebuild
uvicorn app.main:app --reload --port 8000.env 里的 SM_OLLAMA_BASE 指向 http://127.0.0.1:11435(容器的宿主映射)。
不想用模型时,把 SM_EMBED_BACKEND=hashing、SM_LLM_BACKEND=none:
仍然需要 PostgreSQL(compose 提供),但不需要 GPU 和 ollama。
方式三:Docker(推荐,模型也在里面)
docker compose -f deploy/docker-compose.yml up -d --build
# 网页 http://127.0.0.1:8000/ · MCP http://127.0.0.1:8765/mcp
python scripts/verify_docker.py --expect-embed ollama --expect-llm ollama # 10/10默认起 ollama 并导入/补齐模型(ollama-import / ollama-pull);首次需要 Docker Hub 网络。
存储固定 PostgreSQL + pgvector(compose 会起 postgres 服务并等它健康);没有 SQLite 回退路径。
无模型模式(不调用 ollama,但 PG 仍要起):
$env:SM_EMBED_BACKEND="hashing"; $env:SM_EMBED_DIM="512"; $env:SM_LLM_BACKEND="none"
docker compose -f deploy/docker-compose.yml up -d --force-recreate --wait
python scripts/verify_docker.py --expect-embed hashing --expect-llm none# bash
SM_EMBED_BACKEND=hashing SM_EMBED_DIM=512 SM_LLM_BACKEND=none \
docker compose -f deploy/docker-compose.yml up -d --force-recreate --wait本机实测(Docker 29.9.2,镜像 334MB,scripts/verify_docker.py 可复现):
配置 | /health | 规则题 | 数据题 | MCP |
hashing + none(PG) | db=postgres docs=8 chunks=45 | 引用 5 段,抽取式 477 字 |
| 10/10 |
ollama + qwen2.5:7b(PG) | db=postgres docs=8 chunks=45 | 引用 5 段,生成 75 字,首次 0.93s |
| 10/10 |
两个实现细节:
业务数据、工作流、checkpoint 全在 PG;容器只读挂载
corpus/,不挂宿主机data/。mcp服务等app健康后再启动,避免两个进程同时初始化数据库。
Docker Desktop 需要 WSL2,且
bcdedit的hypervisorlaunchtype必须是auto。 本机安装时踩过no virtualization available,记录在 HANDOVER 6.11。
把 Ollama 也放进 Docker(默认)
主 compose 已经包含模型服务,docker compose up -d 会按顺序:
ollama-import:从宿主机~/.ollama/models导入已有模型到命名卷(一次性,已有.imported标记则跳过)ollama:启动 Ollama,容器内11434,宿主映射11435供本地脚本调试ollama-pull:补齐缺失的bge-m3/qwen2.5:7b;拉取失败不阻塞整栈app/mcp:使用http://ollama:11434,建索引并启动
Docker 的服务名/容器名只允许 ASCII([a-zA-Z0-9][a-zA-Z0-9_.-]),所以底层服务名仍是
ollama / ollama-import / ollama-pull / app / mcp / postgres;
每个容器加了中文 label,配一个中文快捷脚本:
.\scripts\docker.ps1 启动 # 起全部(含模型;缺失时自动拉)
.\scripts\docker.ps1 重新构建 # 改了代码后重建 app 镜像
.\scripts\docker.ps1 状态 # 中文 label:应用 / 接口 / 数据库 / 模型 / 模型导入 / 模型拉取
.\scripts\docker.ps1 日志 应用
.\scripts\docker.ps1 停止宿主机上的 ollama 计划任务已禁用;本地跑评估脚本时 .env 指向 127.0.0.1:11435(容器映射端口)。
需要无模型模式时,用 SM_EMBED_BACKEND=hashing SM_EMBED_DIM=512 SM_LLM_BACKEND=none 重建 app。
命令行
python scripts/search_cli.py "会员卡能跨店用吗"
python scripts/search_cli.py --batch # 8 条示例
python scripts/diagnose.py "满减和折扣券能一起用吗" # 分离看两路召回
python scripts/diagnose_dual.py # 两路命中率对比
python scripts/weight_sweep.py # 向量权重对照实验
python scripts/eval.py # 跑金标集,出 recall@k
python scripts/compare_orchestration.py # 手写编排 vs LangGraph 等价性 + 多轮演示
python scripts/verify_e2e.py # 真模型端到端验证(11 条用例)
python scripts/mcp_smoke_test.py # MCP 协议冒烟(stdio,14 项检查)
python scripts/workflow_test.py # 工作流:提案/确认/越权/幂等/审计(9/9)
python scripts/verify_docker.py # Docker 部署验证(app + MCP + 工作流,10 项)多轮会话(同一 thread_id 共享上下文):
curl -X POST http://127.0.0.1:8000/chat \
-H "Content-Type: application/json" \
-d "{\"query\":\"会员卡能跨店用吗\",\"thread_id\":\"u1\"}"
curl -X POST http://127.0.0.1:8000/chat \
-H "Content-Type: application/json" \
-d "{\"query\":\"那退卡呢\",\"thread_id\":\"u1\"}"MCP server(自己实现协议,不用 SDK)
把知识库和经营数据暴露成标准 MCP 工具,任何 MCP 客户端(Claude Desktop / Cursor / Claude Code)都能接。
协议是自己写的:JSON-RPC 2.0 帧、initialize 握手、能力协商、tools/list / tools/call、
resources/list / resources/read,只用标准库,没有装 mcp SDK。
# stdio:MCP 的标准传输,客户端拉起子进程用这个
python scripts/mcp_server.py --stdio
# HTTP:方便 curl 和容器部署
python scripts/mcp_server.py --http 8765
curl -s http://127.0.0.1:8765/health工具 | 用途 |
| 混合检索知识库,返回章节路径、分值和原文片段 |
| 列出文档与切片数 |
| 完整 Agent:路由 + 检索/查库 + 自检重查,返回答案、引用、执行轨迹 |
| 列出全部门店 |
| 白名单经营数据查询,参数校验后执行预编译 SQL |
| 生成客诉工单 / 补货提案(只提案,不落库) |
| 查看待办、确认 / 拒绝,带角色校验与审计 |
语料同时以 storemind://corpus/{doc_id} 资源暴露,客户端可以直接挂进上下文,不用先调工具。
客户端配置见 mcp.example.json(把 <项目绝对路径> 换成实际路径):
{
"mcpServers": {
"storemind": {
"command": "<项目绝对路径>/.venv/Scripts/python.exe",
"args": ["<项目绝对路径>/scripts/mcp_server.py", "--stdio"]
}
}
}协议级验证(握手顺序、通知无响应、三类错误码、真实工具调用、工作流):
python scripts/mcp_smoke_test.py # 14/14
python scripts/mcp_smoke_test.py --zero-dep # 14/14,hashing + none 也能跑两个实现细节:stdio 的 stdout 只允许协议消息,日志全部走 stderr——混一行 print 客户端就解析失败;
工具不存在返回 JSON-RPC -32602,工具跑通但查不到数据返回 isError: true,这两种失败语义不一样。
tools/call 同时返回给人读的 content 和给客户端机器读的 structuredContent。
可执行工作流(提案 → 确认 → 审计)
写操作不让模型直接执行,统一走受控闭环:
propose_ticket / propose_restock
→ pending 提案(不产生业务副作用)
→ 前端确认卡片 / POST /actions/{id}/confirm
→ 角色校验(补货需 manager)
→ 写 tickets / restock_requests + audit_log幂等:同一条提案重复确认只返回第一次结果;
idempotency_key防止重复提案权限:店员可确认客诉工单;补货必须店长确认,越权返回 403 并写审计
审计:
proposed/confirmed/rejected/permission_denied全部留痕演示:打开
http://127.0.0.1:8000/#wf,或在页面右上角点「操作提案」测试:
python scripts/workflow_test.py→ 9/9(提案、确认、越权、幂等、拒绝、校验)Docker 部署验证包含 2 条工作流用例,总计 10/10
网页里能看到什么
页面刻意做成「把黑箱拆开给你看」,而不只是聊天框。每条回答下面有三层可展开信息:
区块 | 内容 | 为什么要有 |
意图徽标 + 路由理由 |
| 让人一眼看到「这个问题为什么走了 SQL 而不是文档」 |
执行轨迹 | 每一步的名称、耗时、判定细节( | 出问题时能定位到具体环节,不是黑箱 |
引用来源 | 命中的章节路径、文档名、相关性分数,点开看原文片段 | 企业知识库第一诉求是「能核对」——答案必须对得上出处 |
答案里的角标可点击,两种编号对应两种来源:
[1]指向文档片段(点开高亮引用卡片,显示原文)[D1]指向经营数据行(点开高亮工具调用表格的对应行)
工具栏显示 store_sales 这类工具调用及其返回的真实聚合数据。
右上角「操作提案」打开受控写操作面板:生成客诉工单/补货提案,确认后落库并写审计;补货需要店长角色。
换成生产配置
默认值已经是「本地 ollama + bge-m3 + qwen2.5:7b」,下面列的是换其他组合的方式。
# 嵌入走本地 ollama 的 bge-m3(默认)
set SM_EMBED_BACKEND=ollama
set SM_OLLAMA_BASE=http://127.0.0.1:11434
set SM_EMBED_MODEL=bge-m3
# 生成走任意 OpenAI 兼容接口(DeepSeek / Qwen / vLLM 都行)
set SM_LLM_BACKEND=openai
set SM_OPENAI_BASE=https://api.deepseek.com/v1
set SM_OPENAI_KEY=sk-xxx
set SM_OPENAI_MODEL=deepseek-chat
python scripts/build_index.py换嵌入后端后索引会自动重建。索引的 meta 表里存了嵌入器签名,
build_index.py 检测到签名变化就强制重建并打印警告:
!! 嵌入器已变更 (hashing:ngramce09b1 -> ollama:bge-m3),已强制重建索引这不是多余的。vector_search 里有一句 if len(v) != len(q): continue——
维度不符的旧向量会被静默跳过,表现是「召回率突然掉了」而不是「配置错了」。
自动重建就是为了避免这种极难排查的故障。
Agent 层:自检 → 改写 → 重查
这是本项目和「RAG 脚本」的分界线。
流水线的做法是:检索 → 拿到 top5 → 生成答案。检索到什么都用。 Agent 的做法是:检索 → 自检证据够不够 → 不够就改写检索词重查 → 再自检。
为什么必须有这一层
三个实测理由:
检索永远返回 top5,哪怕全是不相关内容。 没有自检,模型就拿着 5 段无关资料硬答,看起来很自信,实际是编的。
用户提问和文档用词不一致时,第一次检索会漏。 客人说「吃到变质的酒」,文档写「酒水异味或品质问题」——第一次召回的 top1 未必是正确段落,但只要自检发现「这些资料答不了」并把检索词改写成语料里的说法,第二次就能召回到。
拒答是语义判断,不是打分任务。 README 里那个「拒答阈值标定失败」的结论,最终就是靠这一层解决的——检索分判断不了「资料够不够回答」,得模型说了算。
A/B 对照实验
scripts/probe_agent_ab.py:同一批 24 条规则题,一组关掉 Agent 层,一组开着。
指标 | A 关掉 Agent | B 开启自检重查 | 变化 |
recall@1 | 79.2% | 87.5% | +8.3 |
recall@3 / @5 | 95.8% / 95.8% | 95.8% / 95.8% | +0.0 |
答案引用正确来源 | 95.8% | 95.8% | +0.0(零回退) |
平均延迟 | 887ms | 945ms | +58ms |
最大延迟 | 1520ms | 1633ms | +113ms |
触发重查 | 0/24 | 6/24 | +6 |
重查后 top1 变差 | — | 0 | 零回退 |
recall@1 涨 8.3 个点,答案正确率零回退,延迟基本不变。 成本几乎为零是因为判定分两层:规则层(词面重合度)先行,模型层只在灰色地带才调用。
实现要点
判定分两层,这是成本能压下来的关键:
层 | 判据 | 成本 |
规则层 | 工具拿到数据 → 直接算够;top1 词面重合度 ≥ 0.55 → 算够;≤ 0.15 → 算不够 | 零 |
模型层 | 规则层说不清楚时,才让 LLM 判断「这些资料能回答吗」 | 一次调用 |
改写词用拼接而不是替换。 「客人吃到变质的酒怎么办」改写成「酒水异味 品质问题」之后, 「变了」「客人」这些用户信号就丢了。实际做法是把改写词拼在原句前面, 让 BM25 同时吃到两边的词。
每一轮独立判定,最终只用某一轮的片段。 这一点是踩坑踩出来的: 早先版本把几轮召回取并集,好处是「重查不会让结果变差」, 但坏处更严重——三轮召回混在一起,模型看到的是一锅粥。 实测「客人边吃饭边做爱怎么办」这条,top1 修对了(升级与上报), 答案却变成「现有资料里没有相关规定」,比没有 Agent 层时更差。 检索指标涨了,答案质量反而跌了。 所以现在的规则是:某一轮判 OK 就用那一轮,全都不 OK 就用分数最高的那一轮。
模型层有兜底。 第一版提示词里写了「OK 时第二行输出 -」」, 结果模型在 MISS 时也照抄 MISS\n-`,改写词永远是空的,
整个重查机制等于空转(实测 10 条用例,重查修正 top1 的数量是 0)。
修法有两个:提示词里给两种情况的示例,以及代码层兜底——
模型不给改写词时,从语料的章节标题里挑语义最接近的当检索词。
重查上限是 2 轮。 语料里真没有的内容,改写十次也找不到, 反而拖长延迟、让 trace 变得没法看。
怎么关掉它
set SM_AGENT_MAX_ATTEMPTS=0 # 退回纯流水线A/B 实验就是这么做的。
评估结果
金标集 31 条(24 条规则题 / 5 条数据题 / 2 条应拒答题),python scripts/eval.py 可复现:
bge-m3 嵌入 + qwen2.5:7b 生成(当前默认配置)
指标 | 数值 | 字符哈希时期 |
recall@1 | 87.5% | 81.0% |
recall@3 | 95.8% | 90.5% |
recall@5 | 95.8% | 95.2% |
文档命中 | 95.8% | 95.2% |
答案关键内容命中 | 100.0% | 不适用(当时无生成模型) |
数据题工具选对 | 100.0% | 100.0% |
无答案题正确拒答 | 100.0% | 0%(当时无 LLM,无法拒答) |
路由准确率 | 100.0% | 89.3% |
编排等价性 | 6/6 | 6/6 |
recall@1 当前为 87.5%(bge-m3 + PG)。自检重查的判定由模型做,重复运行会有波动; 下面的 A/B 对照里 A 组 79.2%、B 组 87.5%,这组差异才是「Agent 层带来多少」的量化。
端到端真实验证(scripts/verify_e2e.py,走 HTTP 打真模型,11 条用例):11/11 无思考过程泄漏,10/11 章节命中。
答案稳定性(scripts/probe_stability.py,同一问题连跑 5 次):关键内容 5/5 稳定。
PostgreSQL 后端实跑:87.5% / 95.8% / 95.8%,A/B 延迟 +58ms。所有验证脚本默认连
deploy/docker-compose.yml 里的 postgres(宿主 5433),不再有第二套存储。
关于向量权重的对照实验
scripts/weight_sweep.py 扫 8 档权重,用同一份金标集量:
嵌入后端 | 权重 0.0 | 0.15 | 0.5 | 1.0 | 2.0 |
字符哈希 | 81.0% | 81.0% | 81.0% | 81.0% | 81.0% |
bge-m3 | 85.7% | 85.7% | 85.7% | 85.7% | 85.7% |
结论有点反直觉,但 scripts/diagnose_dual.py 解释清楚了:
在 bge-m3 + 当前 45 个 chunk 的规模下,两路召回都是 100% 命中(21/21 规则题,
向量路和 BM25 路都召回了正解),所以调权重看不出差别。
这说明向量路从 hashing 时代的「负资产」变成了「等效资产」——不是「更好」,是「不再拖后腿」。
hashing 时代把权重压到 0.15,是因为实测「满减和折扣券能一起用吗」这条 BM25 把正解排第 1,向量路却把「抖音素材留存」排第 1,RRF 奖励「两路都出现」 反而把正解稀释到第 3。同一个参数在换嵌入模型后必须重新标定, 这也是我给索引加了嵌入器签名的原因——见下面第 6 条。
本地模型方案
不用任何云 API。本机 RTX 5060(8GB)跑 ollama:
scoop install ollama
ollama serve # 默认 127.0.0.1:11434
ollama pull bge-m3 # 1.2GB,嵌入
ollama pull qwen2.5:7b # 4.7GB,生成用途 | 模型 | 体积 | 热态延迟 |
嵌入 | bge-m3 | 1.2GB / 1024 维 | 单条 14ms,45 条批量 9.3s |
生成 | qwen2.5:7b | 4.7GB | 400–900ms/次 |
为什么不用 qwen3(混合推理模型)——scripts/probe_models.py 四种配置实测:
配置 | 输出 | 耗时 | 结论 |
qwen3:4b | 593 字,混着「规则要求」的复述 | 3.8s | 不合格 |
qwen3:4b | content 空,推理 1287 字没跑完被截断 | 8.1s | 不合格 |
qwen2.5:7b | 39 字,干净带引用 | 401ms | 采用 |
附带一个发现:qwen3:4b 在「储值 2000 送多少」上答的是「赠送 2.00 元」, 而语料原文是 300 元——小模型连「原样引用数字」这条硬要求都做不到。 qwen2.5:7b 答的是 300 元。系统提示词里第 3 条「涉及金额必须原样引用」不是摆设, 但只有更强的模型能真正执行。
keep_alive 是个坑:ollama 默认 5 分钟后卸载模型,下次请求要重新加载权重,
延迟从 0.4 秒跳到 17 秒。所以每次请求都显式带 keep_alive: "30m"。
六个关键设计决策
面试被追问的就是这几条,每条都有对照实验或数据支撑。
1. 为什么不按固定 token 切片
门店 SOP 的知识单元是「章节」,一个章节讲一件事。按 512 token 硬切会把「跨店核销的三种情况」切成两半,检索出来的全是残句。
所以按 Markdown 标题层级切分,并保留 section_path(如 活动核销规则 > 叠加规则)。嵌入时把标题路径拼在正文前——标题是极强的人工语义标签,扔掉可惜。
踩过的坑:第一版 overlap 是直接截上一块最后 60 个字符贴到下一块开头,结果每个 chunk 开头都是半句话,生成时模型读到错位的上文。改成按完整句子从后往前取,且只在同一章节内做 overlap。
2. 为什么用 PostgreSQL + pgvector,而不是独立向量库 / 纯内存检索
部署路径是 PostgreSQL + pgvector:知识库向量、经营数据、工作流/审计、LangGraph checkpoint 都在同一个库,能做 JOIN、走事务、备份一套搞定;比 Qdrant / Milvus 少一个要运维的组件。
运行时只有这一个后端:build_store / build_ops_tools / WorkflowDB / PostgresSaver 全部固定 PG,SQLite 已移除。本地跑脚本需要 compose 的 postgres(宿主 5433);Docker 一键起。
为什么不把 BM25 下推给 PG:chunk_terms + Python 打分保留在应用层,换成 tsvector 是 TF-IDF 类实现,会改变词法召回;中文全文检索的生产方案是 zhparser / pg_jieba。向量走 HNSW,词法走应用层 BM25,RRF 融合也在应用层,k 和权重能跟着评估集调。
小数据不是 PG 的强项:45 个 chunk 下 HNSW + 网络往返有明显固定开销,单次 hybrid_search 约 60ms(含 bge-m3 嵌入);PG 的收益在并发、多实例和 10 万 chunk 以上的 ANN,不是单机小数据。这个结论比“PG 更快”更值得讲。
3. 为什么要混合检索,以及一个反直觉的发现
只靠向量召回会漏掉专有名词——「黑牌」是威士忌不是颜色,「连娱宝」是产品名,这类词在嵌入空间里和字面完全不相关。只靠关键词召回则对「那如果客人喝了半瓶怎么办」这种口语长句几乎无效。所以两路都召,用 RRF 融合。
关键发现:字符哈希嵌入在这类查询上是负资产。
用例「满减和折扣券能一起用吗」:BM25 单独把正解「叠加规则」排第 1(40.9 分,领先第二名 60%),向量路却把「抖音素材留存」排第 1。RRF 会奖励「两路都出现」的片段,于是正解被稀释到第 3。
把向量权重从 1.0 降到 0.15 后,正解回到第 1。
RRF 的副作用不是加噪声,是稀释。
换 bge-m3 之后的结论不一样:scripts/diagnose_dual.py 实测两路召回都是 100% 命中,
向量路从「负资产」变成「等效资产」。但它没有变成决定性因素——
因为 BM25 在这个语料规模上已经够用。所以 0.15 这个值不需要改回 1.0,但也不能说向量路有决定性贡献。
这个结论本身就是面试素材:混合检索里两路的价值不是固定的,取决于嵌入模型的质量。 参数不能跨后端照搬。
4. 为什么不让 LLM 自由生成 SQL
三个理由:注入面(门店名来自用户输入)、幻觉(模型会编不存在的表名,线上表现是随机 500)、权限不可控(自由 SQL 意味着能 SELECT 员工薪资表)。
所以只暴露白名单工具,参数用 Pydantic 校验,SQL 全部预编译。模型能做的是「选哪个工具、填什么参数」,不是「写 SQL」。
踩过的坑:第一版用正则 [\u4e00-\u9fff]{2,6}店 抽门店名,「上周广州天河店卖了多少」整段被吃成门店名「上周广州天河店」,查库必然为空。改成门店白名单匹配,顺带解决了简称(天河店 → 广州天河店)。
5. 为什么规则路由优先于 LLM 分类
门店高频问题高度模式化,「能…吗 / 怎么 / 规定」这类句式用正则判断准确率高于小模型,而且零成本、零延迟。规则命中不了才走 LLM——不是所有环节都值得塞一次模型调用。
踩过的坑:第一版用关键词包含判断,「会员卡能跨店用吗」不含「能不能」三个字所以没命中。改成句式正则(能[^,。?!]{0,10}[吗嘛])后路由准确率上来了。
第二个坑:_match 按 pattern 计数而不按命中次数。「深圳福田店热销排行前五」里热销、排行、前五三个词都落在同一个 pattern,只算 1 个弱信号,不足 2 个的门槛,被误判成规则意图、跳过了数据查询。改成 findall 计数后路由准确率 89.3% → 100%。
6. 为什么先手写状态机,再迁到 LangGraph
不是一开始就用图。先把状态机的形状自己画一遍,才知道哪些节点该共享、哪些边该有条件、哪些状态需要持久化。手写版跑通、评估集跑出数字之后,迁移是平移——节点函数一行没改,只换了编排。
迁移的真实收益(不是"用了框架"这种理由):
并行 fan-out。手写版要自己管「两路都要」时的并发和异常合并;图里
add_edge("route","a")+add_edge("route","b")就是并行,merge 自动等两边。checkpoint。多轮会话持久化在手写版是自己维护一个 dict,图里是
thread_id的事,还附带中断恢复。state 落在 PostgreSQL,服务重启会话还在。可观测。每个节点、每条边都在 trace 里,出事能定位到边。
环。这是加 Agent 层之后才体会到的。手写版的自检重查是一个
for循环加一堆状态回退; 图里就是judge → rewrite → retrieve_again → judge两条边。 但图版不能直接循环回retrieve_guard——它是 join 的成员,循环回去会破坏 join 语义。 所以得单独开一个retrieve_again节点。
迁移踩了三个坑,都不在文档显眼处:
join 的语义是「等待列表里每个节点都完成」,不是「等待已激活的节点」。 我先用
add_edge(["retrieve","fallback"], "answer"),policy 路径下 fallback 不激活,answer 就永远等不到——实测答案是空字符串。改成两个 guard 节点必然执行(不需要的那条返回空 dict),join 才成立。多入边的节点会被每条边各触发一次。 第一次改完 both 意图下生成跑了两次,trace 里两个
generate,后一次把前一次覆盖了。所以必须有唯一的汇合点。trace 不能放进 state。 state 会被 checkpointer 持久化,
steps用了operator.addreducer 之后跨轮次累积——第二轮能看到第一轮的步骤。trace 是运行时观测数据,改用contextvars在 state 外面收集。
这三个坑写在 app/agent/graph.py 的注释里。
第四个坑是加 Agent 层时踩的:环不能回到 join 成员上。
retrieve_guard 和 data_guard 组成 join,重查如果循环回 retrieve_guard,
join 等的是「本次 superstep 里两个成员都到过」,只回一个成员就永远等不到另一个。
解法是单独开 retrieve_again 节点,不参与 join。
最终验证:手写版(while 循环)和图版(图里的环)跑同一批 query, intent、top1 章节、召回集合全部一致,6/6 通过。 这才是「我用了 LangGraph」这句话该有的证据强度—— 不是「框架能跑」,而是「同一套逻辑两种编排,输出可验证地相同」。
7. 防幻觉的四道防线
接上 LLM 之后,「答案看起来对」不等于「答案对」。实测抓到四类真问题,每一类都加了一道代码层的防线——不指望模型自己守规矩。
抓到的问题 | 现象 | 防线 |
引用捏造 | 纯数据查询(召回 0 段)时模型凭空写 |
|
来源混淆 | 参考资料有编号、经营数据没编号,模型给数据编了个 | 经营数据改用 |
数据答不全 | 工具返回 2 个告急品项,答案只说了 1 个 |
|
条件性条款被跳过 | 客人说「吃到坏了」,答案完全没提「身体不适要就医」 |
|
第 3 条值得单独说:改 prompt 根本不管用。我试过给每个数据行加编号、 在系统提示词里写「不要只挑其中一条」、把字段名翻成中文——模型还是会漏。 7B 模型在列表上就是会只取第一条。最后是代码兜底才解决的。
第 4 条更有分量。SOP 里大量条款是条件句——「如果客人提出身体不适,立即建议就医」, 而客人问「吃到坏了怎么办」时并没说「身体不适」。模型读到这条会理解成 「当前场景不满足条件」而跳过。实测「客人吃到了坏了怎么办」连跑 5 次, 「建议就医」出现 0/5——不是随机性,是系统性遗漏。
这在食品安全场景是真实事故风险:漏掉就医建议, 后面所有流程(退单、赔付)都建立在「客人身体无恙」的假设上。 所以这类条款不能靠模型自觉,按场景关键词在代码层强制补上。修完 0/5 → 5/5。
在有确定答案的场景,正确性不能交给概率模型。
门店店长拿着这份提示去补货,漏一个品项就是真实的业务事故。 在有确定答案的场景(结构化查询),正确性不能交给概率模型。
另一个相关决策:等价性测试的判据改了。
接上 LLM 之后,两次调用同一问题会因采样随机性产生不同措辞,
逐字比对答案必然失败——那测的是 LLM 的随机性,不是编排的正确性。
改成比对「intent + top1 章节 + 召回集合」,这才是编排真正决定的部分。
(另外提取数字前要先剥掉 [1] 引用标记,否则引用编号会被当成内容数字。)
还有一个配套的评估维度:答案关键内容命中。
recall@k 只说明「召回到正确的段落」,不说明「答案里真的包含了关键信息」。
金标集加了 expect_answer_contains 字段后,p22(吃到坏了)这条的
「章节命中 @3」和「内容命中 ✔」是两个独立信号——内容漏了但章节命中,在旧指标下是看不出来的。
8. 为什么索引要记录嵌入器签名
换嵌入后端时如果忘了重建索引,不会报错。
vector_search 里有一句 if len(v) != len(q): continue——
维度不符的旧向量被静默跳过,表现是「召回率突然掉了」而不是「配置错了」,极难排查。
所以索引的 meta 表里存了嵌入器签名(hashing:ngramce09b1 / ollama:bge-m3),
build_index.py 检测到签名变化就强制重建并打印警告:
!! 嵌入器已变更 (hashing:ngramce09b1 -> ollama:bge-m3),已强制重建索引签名里不含 dim:ollama 的向量维度要等首次嵌入后才知道, 写进签名会导致每次运行都判定为「变了」而误触发重建。 hashing 那边已经把维度编码进模型名里了。
已知限制
写在最前面,因为这几条我试过并且知道为什么难:
语料是构造的。8 篇文档、45 个 chunk,按「连娱宝」公开功能描述反推知识域造的。企业知识库项目拿不到真实数据,所以重点放在语料分布设计:非结构化规则 + 结构化事实 + 含糊提问三类混合。规模上也偏小——45 个 chunk 上 BM25 已经够用,向量路的贡献被掩盖了。语料扩到几百篇后,两路的互补性才会显现。
top1 排序不理想,这是当前最明显的短板。以「什么情况必须叫店长」和「客人吃到了坏了怎么办」为例,正解都在召回列表的第 2–3 位,第 1 位是「第一周必须掌握的六件事」「三条基本原则」这类泛化段落。答案内容正确、引用编号也正确(指向第 2–3 段),但用户点 top1 看到的不是最相关的那段。上 rerank 模型能解决(bge-reranker),当前只做了章节路径加权这种规则重排。语料只有 45 个 chunk 时影响有限,扩到几百篇后会变成主要瓶颈。
生成模型只有 7B,指令遵循不是满分。qwen2.5:7b 在 11 条端到端用例里做到 0 次思考泄漏、0 次引用捏造,但仍偶发输出「首先确认是…」这类正常中文开头(不算泄漏)。系统提示词里的格式约束只能约束大部分情况,剩下的靠
_strip_lead_noise后处理兜底。纯数据查询不做 RRF。数据意图直接走工具,不检索。好处是快、准;代价是无法回答「结合历史订单看这个品项的动销趋势」这类需要文档 + 数据交叉的问题。
没有 rerank。用的是章节路径加权(query 词命中章节标题加分)这种规则重排,没有 bge-reranker 这类交叉编码器模型。45 个 chunk 规模下不需要,chunk 上到几百就该加了。
Agent 层的判定是「够不够」,不是「对不对」。 自检只回答「这些资料能回答这个问题吗」,不验证答案本身对不对。判 OK 之后模型仍可能答错——那要靠别的手段(交叉验证、人工抽检)。另外判定器本身有误判率:6/24 的用例触发了重查,其中多数第一轮其实已经够用,属于无谓开销。
改写是关键词层面的,不是语义层面的。 靠模型给几个术语拼在原句前面,对「絮状物 → 酒水异味」这种有效,对「客人投诉了但没说原因」这种无能为力。后者要真正的 query rewriting(用 LLM 生成完整的检索式),当前版本没做。
强制条款是关键词匹配,不是真正的条件推理。
_ensure_mandatory_rules靠触发词命中来补条款,能挡住「吃到坏了」这类明显场景,但挡不住「客人喝完第二天不舒服」这种换了说法的情况。要做对得让模型显式判断条件是否成立,那是另一个量级的工程。
目录
app/
config.py 全部可插拔配置,环境变量驱动
ingest.py 索引构建(按内容 hash 增量)
main.py FastAPI 入口
api/ 接口层(薄,只做校验和异常映射)
agent/
router.py 意图路由(句式正则,强/弱信号分层)
judge.py 证据充分性判定 + 查询改写(Agent 层的核心)
pipeline.py 手写状态机:route → retrieve/query → judge/rewrite 循环 → answer
graph.py LangGraph 编排(并行 fan-out + 自检重查环 + PostgreSQL checkpointer)
llm.py 生成层(none / ollama / openai 兼容)
rag/
chunker.py 标题层级语义切分
embedder.py 嵌入层(hashing / ollama)
store.py 共享类型(Hit / tokenize)与 build_store 工厂
pg_store.py PostgreSQL + pgvector(HNSW),唯一存储实现
retriever.py 混合召回 + 加权 RRF + 路径加权重排
tools/
ops_tools.py 共享类型(ToolResult / Period)与 build_ops_tools 工厂
pg_ops.py 白名单经营数据工具(PostgreSQL)
workflow/
db.py 操作提案 / 审计 / 工单表(PostgreSQL)
service.py 提案 → 确认/拒绝,权限、幂等、审计
mcp/
server.py 自实现 MCP server(JSON-RPC 2.0 + tools/resources + stdio/HTTP)
static/
index.html 单文件前端页面,零构建,fetch 直接打 /chat
corpus/ 门店运营语料(sop / rules / product)
eval/golden.json 金标集 31 条
scripts/
seed_ops_db.py 生成经营数据
build_index.py 建索引(增量 + 嵌入器签名检测)
search_cli.py 命令行检索
diagnose.py 分离两路召回
diagnose_dual.py 两路命中率对比(判断向量路是否有贡献)
weight_sweep.py 向量权重对照实验
eval.py 跑金标集出 recall@k
smoke_test.py 端到端冒烟
calibrate.py 拒答阈值标定(结论:不可行,见设计决策 7)
compare_orchestration.py 手写编排 vs LangGraph 等价性 + 多轮演示
probe_models.py 生成模型选型对照(qwen3 vs qwen2.5)
probe_think.py think 参数行为探测
probe_stability.py 答案稳定性:同一问题连跑 N 次统计关键内容命中率
probe_agent.py Agent 层实测:自检重查是否真的修正了 top1
probe_agent_ab.py Agent 层 A/B 对照:开关自检重查的收益量化
verify_e2e.py 真模型端到端验证(走 HTTP,11 条用例)
mcp_server.py MCP server 启动入口(不依赖 cwd)
mcp_smoke_test.py MCP 协议冒烟:握手 / 通知 / 工具 / 资源 / 三类错误码
verify_docker.py Docker 部署验证:app 三个接口 + MCP 四个方法 + 工作流
docker.ps1 中文 Docker 快捷命令(启动 / 启动模型 / 状态 / 日志)
workflow_test.py 工作流:提案/确认/越权/幂等/审计
dump_prompt.py 导出真实 prompt 排查
dump_chunks.py 导出语料切片结果,定位某句话落在哪一段
dump_judge.py 导出 judge 模型的原始输出,查解析失败
setup_local.bat 一键起 ollama + 拉模型 + 建索引
deploy/ docker-compose(ollama + PG + app + MCP)与 pgvector schema
mcp.example.json MCP 客户端配置示例路线图
LangGraph 编排:并行 fan-out + checkpoint 多轮会话
单文件前端页面:意图徽标 + 执行轨迹 + 可展开引用溯源(文档引用 + 数据引用分别可点)
本地模型链路:ollama + bge-m3 + qwen2.5:7b,零云 API 依赖
防幻觉四道防线:引用校验 + 编号分离 + 数据条目兜底 + 条件性条款强制
拒答:交给 LLM 做语义判断,评估报告里从 N/A 变成 100%
答案稳定性验证:同一问题连跑 5 次,关键内容 5/5
Agent 层:自检 → 改写 → 重查循环。图里唯一的条件边和环, A/B 对照 recall@1 +8.3 个点、答案正确率零回退、延迟 +58ms
自写 MCP server:自己实现 JSON-RPC 2.0 与 MCP 握手,暴露 13 个工具 + 8 篇语料资源, stdio / HTTP 两种传输,协议冒烟 14/14(含零依赖模式)
可执行工作流:客诉工单 / 补货申请,提案 → 人工确认 → 落库 + 审计; 角色权限、幂等、拒绝全部覆盖,测试 9/9
Docker 化:
docker compose up起 PG + app + MCP,无需 GPU / 模型也能跑; 镜像 334MB,部署验证 10/10(hashing + none / ollama 各测一次)全栈 PostgreSQL + pgvector:HNSW 索引,知识库 / 经营数据 / 工作流 / 会话 checkpoint 都在 PG;SQLite 已从运行时移除
Redis 语义缓存:query embedding 相似度 > 0.95 直接返回
加 rerank 模型(bge-reranker),解决 top1 排序不理想这个最大短板
LLM 版指代消解:当前「那退卡呢」靠规则借上文,复杂追问会失效
语料扩到 20 篇以上,让混合检索的两路互补性显现出来
License
MIT
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
This server cannot be deployed
Maintenance
Related MCP Connectors
Shared business knowledge, tools and skills for AI agents, with citations and approval controls.
One governed MCP endpoint for 40+ business systems. Read-only, source-cited answers.
Salesforce-grounded retrieval, diagnoses, and a vetted-Force marketplace for MCP clients.
Knowledge base MCP for AI agents on iknow.dev. Search, read, and maintain via OAuth.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceAn MCP server exposing internal business operations as tools — task management (create, list, update status) and RAG-style semantic search over an internal knowledge base (leave, expense, and onboarding policies) that any MCP-compatible AI agent can call directly for grounded, non-hallucinated answers.-
- FlicenseNot gradedqualityCmaintenanceEnables MCP-compatible AI agents to operate a retail store’s administrative backend and API, managing catalog, inventory, orders, and related store data through shared business logic.-
- FlicenseNot gradedqualityBmaintenanceEnables agents and MCP clients to look up shipment tracking status, SAP order sync errors, ServiceNow incident tickets, and internal SOP runbooks through natural-language tool calls, with every execution written to a SQLite audit trail.-
- FlicenseNot gradedqualityCmaintenanceServes a governed knowledge system of record over MCP, letting agents answer questions with citations drawn only from approved documents and explicitly decline when the record does not cover the question.34 npm-