第04章 - 一张图「智能体」实战

「一张图」在国土、规划、交通、应急这些行业里几乎是通用名词:把矢量、栅格、实时点位、视频、文档叠在一张底图上,配一个图层树、一个属性面板、几组专题配置。它的交互模型是「人找图层、人配符号、人做分析」,交互成本随图层数量线性上涨——一个 300 图层的项目,新人找「学校」要翻三层目录。本章要做的改造只有一句话:用户用自然语言说出意图,系统在受控的工具集内自行编排查询、分析、渲染、定位,最后把「图层 + 图例 + 结论」一起还给用户。

读完本章你能交付四样东西:一张图后端的工具层设计(工具怎么切、参数怎么定、工具粒度按什么阈值验收、错误码怎么统一);Qwen3 / Qwen-Agent 或 DeepSeek V4 两档模型跑通 Function Calling 的最小闭环,并按「编排 / 路由 / 视觉」三角色分工(4.3.5);符合 MCP 2026-07-28 规范的 GIS MCP server(stateless 核心 + Streamable HTTP),带白名单、分页、超时与审计;以及一个可截断、可句柄、可回放的完整链路——trace 能导出成 JSONL,交给 DSH headless 批量回放做回归(4.6.4)。

智能体本身不难,难在把 GIS 操作拆成模型不会用错、用错也能被拦住的工具;而 2026 年的新变量是「工具不再绑死在一个客户端里」:封装成 MCP server 之后,DSH、自有 Web 智能体、第三方地图客户端共用同一套能力,权限也只需要在 server 侧收口一次。

学习目标:能独立设计并实现「智能一张图」的能力层与智能体层;掌握 Function Calling 的工具定义与失败恢复;能把 GIS 能力封装为 MCP server 并接入 DeepSeek Harness(DSH);能用 50~200 条评测样本把「感觉能用」变成可回归的指标。

前置知识:熟悉 Python 3.10+ 与 FastAPI/Flask 至少一种;了解 OGC 服务(WMS/WMFS/WFS)与 Web 墨卡托、EPSG:4490 等常用坐标系;用过 MongoDB 或 PostGIS;读得懂 JSON Schema;本机按第 01 章装好了 DSH(命令行 dsh)。

4.1 智能一张图的整体思路

先给结论:智能一张图不是把聊天框贴在页面右下角,而是把「人对图层的操作」抽象成一组语义化工具,再让模型在这组工具里做选择与编排。聊天框只是入口,真正决定上限的是能力层——工具切得对不对、参数是否可枚举、返回体是否够小。下面按「差异 → 架构 → 路线 → 验收」四步把整体思路定下来。

4.1.1 传统一张图与智能一张图

两者的差别不在前端框架,而在谁来做编排。传统模式下编排是人(点目录、配符号、拉分析工具);智能模式下编排是模型,人只给意图。这带来一个直接后果:传统模式可以容忍含糊的图层命名(老员工知道「那个绿色的」是什么),智能模式不行,工具和字段必须有机器可读的描述。

维度 传统一张图 智能一张图
入口 图层树 + 工具栏 + 属性面板 对话输入框(可保留图层树)
找图层 人按目录逐级展开 模型按描述检索 search_layers
配符号 人在专题面板里选字段与分级 模型输出结构化渲染规则,前端执行
做分析 人选工具、画范围、填参数 模型编排 buffer → overlay → stat
结果 图 + 属性表,结论靠人 图 + 图例 + 一句结论 + 可展开的证据
失败 用户自己发现「点错了」 工具返回结构化错误,模型改参重试或降级询问
扩展成本 每加一个功能加一个按钮 每加一个能力加一个带 schema 的工具

注意最后一行:智能一张图不是「少做功能」,而是「多做一层工具契约」。同样一个缓冲区分析,传统模式写一个按钮的回调;智能模式要写工具描述、参数 schema、返回值约定、错误码,还要进评测集。这是 10~20 人日量级的活,不是一周做完的演示。

4.1.2 四层核心加交互层

把系统拆成下面的五块。前四层是「系统」,第五层只是前四层的投影——这条分层不是为了画图好看,而是为了划清安全边界:模型只能看见第 3 层,看不见第 1、2 层。

┌──────────────────────────────────────────────────────┐
│ 5 交互层   对话面板 / 地图容器 / 图层树 / 图例 / 回执卡   │
├──────────────────────────────────────────────────────┤
│ 4 智能体层 意图理解 → 任务规划 → 工具调用 → 结果解释      │
│            (模型 + 消息循环 + 黑板状态 + 记忆)          │
├──────────────────────────────────────────────────────┤
│ 3 能力层   查询 / 渲染 / 分析 / 定位 / 导出(工具的集合)  │
│            (严格白名单、行数上限、超时、审计)            │
├──────────────────────────────────────────────────────┤
│ 2 服务层   WMS / WMTS / WFS / MVT / 检索 API / 空间库     │
├──────────────────────────────────────────────────────┤
│ 1 数据层   矢量(行政区、设施)/ 栅格(影像、DEM)          │
│            实时(告警、车辆)/ 文档(规划文本、元数据)      │
└──────────────────────────────────────────────────────┘

每层的职责与本章对应的实现:

层 职责 不该做的事 本章对应
数据层 存数据、建空间索引 不做业务判断 MongoDB/PostGIS 集合与索引
服务层 标准协议出图、出要素 不放业务逻辑 已有 WMS/WMTS/MVT 端点
能力层 把服务包装成语义化工具 不直接执行任意查询 4.5 的 MongoDBTool/MapTool
智能体层 选工具、填参数、组织回答 不碰数据库、不生成 SQL 4.2~4.4
交互层 执行地图指令、展示证据 不做意图理解 4.6 的地图指令协议

坑:常见的第一版错误是让模型「直接调 WMS 的 GetMap URL 拼参数」。一旦模型拼错坐标系或图层名,错误信息是一张写着异常文本的 PNG,模型完全无法从结果里学习。能力层必须把「图层不存在」「参数非法」变成结构化 JSON,模型才有机会自我修正。

4.1.3 三条落地路线与成本

不要一上来就写智能体。先判断你的意图空间有多大:如果只有「放大、定位、开关图层」这十几条单步指令,路线①两天就能上线;如果你要处理「把这三个街道的学校按社区密度分色,再叠加医院」这种组合意图,只有路线③能收场。

路线 实现 何时够用 成本 典型失效
① 固定指令集 关键词/正则 + 命令映射 意图 ≤ 30 条、单步、无参数组合 1~2 人日 「放大到学校和医院的范围内」拆不开
② RAG + 模板 意图检索 + 槽位抽取 + 命令模板 句式固定、参数少、有明确话术 3~5 人日 槽位抽错一个整条失败,无法自我纠正
③ 智能体 Function Calling 模型选工具、填参数、多轮循环 组合意图、多步编排、结果需解释 10~20 人日 工具乱调、参数幻觉、成本与延迟上升

路线①的最小实现就是这张表本身,代码不到 20 行:

# route_v1.py —— 路线①:关键词路由,先跑起来,别急着上模型
from re import compile as re_compile

ROUTES = [
    (re_compile(r"(放大|定位|飞到|缩放到)"), "map.zoom_to"),
    (re_compile(r"(打开|显示|叠加).*(图层|专题)"), "map.toggle_layer"),
    (re_compile(r"(多少|几个|统计|分布).*(个|所|家|处)?"), "analysis.stat"),
    (re_compile(r"(量算|测距|面积)"), "analysis.measure"),
]

def route(text: str) -> str:
    for pattern, tool in ROUTES:
        if pattern.search(text):
            return tool
    return "fallback.chat"

if __name__ == "__main__":
    samples = ["放大到余杭区", "把教育设施图层打开", "这个街道有多少所学校"]
    for q in samples:
        print(q, "->", route(q))

实际工程的推荐路径是:路线①做兜底(模型不可用、超时、被限流时仍能执行固定短指令),路线③做主力,路线②只在你已有高质量意图库时用于意图分类,省一次大模型调用。三者不是替代关系,而是同一张图上的不同入口。

4.1.4 成功标准:可解释、可回放

不要把验收标准定成「能聊天」。能聊天的一周就能演示,但它无法回答三个问题:这次回答用了哪些图层?结论是按哪条统计口径算的?同样的输入换一个模型版本还成立吗?把标准落到可测的六个数上:

指标 口径 上线门槛
意图准确率 首轮判对意图类别的样本占比 ≥ 92%
工具选择准确率 期望工具序列与实际一致(允许一次重试) ≥ 85%
参数正确率 关键参数(行政区码、图层 id、时间窗)正确 ≥ 95%
端到端成功率 无人工干预完成且地图可交互 ≥ 80%
平均调用轮数 每条请求的工具调用次数 ≤ 5
可回放率 能按 trace 重放并得到同一图层的请求占比 100%

「可解释」落地为一个字段:每次回答附 trace_id,前端「查看依据」展开工具调用链;「可回放」落地为一张表:把用户输入、工具调用、参数、结果摘要按顺序存下来,重放时固定模型版本与工具版本。没有这两样,第 4.8 节的评测无从做起。

坑:把「平均调用轮数」当成效率指标去压。硬压到 2 轮以内,模型会倾向选「大而全」的工具,参数爆炸、错误率上升。正确做法是先让工具粒度对齐意图(第 4.4 节的三原则),轮数自然降到 3~5。

坑:用生产流量做评测集。真实请求里 80% 是「放大一点」「关掉这个图层」,用它算出的端到端成功率虚高,测不出组合意图的问题。评测集必须单独构造,且包含至少 30% 的负样本(无法完成、需要澄清、越权)。

4.2 智能体实现的基本原理

4.2.1 一个循环:观察、规划、行动、再看

智能体的本质是一个带工具的 while 循环。宿主(你的后端服务)负责三件事:把用户输入与地图当前状态打包成消息、调用模型、执行模型指定的工具并把结果塞回消息列表;模型只负责在每一步决定「下一步调什么」。循环的退出条件有两个:模型认为可以作答(不再请求工具),或达到步数上限被强制收敛。

                 ┌──────────────────────────────┐
                 │  用户输入 + 地图状态快照        │
                 └──────────────┬───────────────┘
                                ▼
                    ┌───────────────────────┐
              ┌────▶│ 1 观察 observe         │  拼装 messages:
              │     │   history + state     │  system / user / tool
              │     └───────────┬───────────┘
              │                 ▼
              │     ┌───────────────────────┐
              │     │ 2 规划 plan            │  模型输出:
              │     │  选工具 + 填参数        │  tool_calls 或 final
              │     └───────────┬───────────┘
              │                 ▼
              │        ┌────────────────┐
              │        │ 有 tool_calls? │
              │        └───┬────────┬───┘
              │        是 │        │ 否
              │            ▼        ▼
              │  ┌──────────────┐ ┌──────────────────┐
              │  │ 3 行动 act    │ │ 5 收敛 finalize   │
              │  │ 校验→执行→审计 │ │ 图层+图例+结论    │
              │  └──────┬───────┘ └──────────────────┘
              │         ▼
              │  ┌──────────────┐
              └──│ 4 观察结果    │  成功:塞回 tool 消息
                 │ ok / error    │  失败:错误码 + 建议
                 └──────────────┘

用伪代码把循环交代清楚。真实代码里 step 上限、超时、并发、取消信号都要有,缺一个就会在生产环境里出现「用户关了页面,后端还在烧 token」:

# 伪代码:宿主侧的消息循环
messages = [system_prompt] + history + [user_msg, state_snapshot]
for step in range(MAX_STEPS):                 # MAX_STEPS 建议 6~8
    if deadline_exceeded(): return degrade("timeout")
    resp = llm.chat(messages, tools=TOOL_SCHEMAS, temperature=0.1)
    if not resp.tool_calls:
        return finalize(resp.content)
    for call in resp.tool_calls:              # 只读工具可并发,写工具串行
        args = validate(call.name, call.arguments)
        if args.error:
            result = {"ok": False, "code": "INVALID_PARAM", "hint": args.error}
        else:
            result = registry.execute(call.name, args.value)
        messages.append({"role": "tool", "tool_call_id": call.id,
                         "content": compress(result)})
return degrade("max_steps")

4.2.2 三种范式:ReAct、Plan-and-Execute、Reflexion

同一个循环,差别在「什么时候想、想多少」。选错范式的典型症状是:用 ReAct 做长任务,走到第五步忘了最初目标;用 Plan-and-Execute 做短指令,计划开销比执行还大。

范式 机制 适合 不适合 成本
ReAct 每步都思考再行动,边做边看 3~5 步、结果影响下一步 步骤 > 8、需要全局一致性 中(多轮模型调用)
Plan-and-Execute 先出完整计划,再逐步执行,可重规划 多子任务、需要先给用户预览 单步指令、高频问答 低到中(计划一次,执行可并行)
Reflexion 一次失败后生成反思,重试时带上反思 参数类错误多、可自愈 上游数据错误(重试也错) 高(额外一轮反思)

GIS 场景的实用组合是分层:外层用 Plan-and-Execute 把一句话拆成 2~4 个子任务(查询、统计、渲染),子任务内部用 ReAct 处理「图层不存在就换检索词」这类局部问题,最后仅在参数校验失败时启用一次 Reflexion(最多一次,防止死循环)。第 4.6 节的 trace 就是这个结构。

# 伪代码:分层编排
plan = planner(user_text)          # Plan-and-Execute:输出子任务列表
for task in plan.tasks:
    if task.type == "query":
        rows = react_loop(task, tools=[search_layers, query_features])
    elif task.type == "analysis":
        rows = react_loop(task, tools=[buffer, overlay, stat])
    elif task.type == "render":
        spec = bind_render_rule(task, rows)   # 结构性绑定,不让模型写 SLD
    if task.failed and task.retryable:
        task = reflexion_once(task)           # 只反思一次

4.2.3 Function Calling 到底发生了什么

模型没有「执行」任何东西,它只是输出一段结构化 JSON,宿主按名字找到函数、把参数反序列化、执行、再把结果作为一条 role=tool 的消息塞回上下文。这个「模型—宿主」的缝隙就是你全部的安全边界:模型能做的上限,等于你注册的工具集,且每个工具内部还有自己的白名单与校验。

一次调用的完整数据流:

{
  "model": "qwen3-32b",
  "messages": [
    {"role": "system", "content": "你是规划一张图助手,只能调用给定工具。"},
    {"role": "user", "content": "余杭区有多少所学校?"}
  ],
  "tools": [{"type": "function", "function": {"name": "stat_features", "description": "按行政区和设施类型统计要素数量", "parameters": {"type": "object", "properties": {"adcode": {"type": "string"}, "facility_type": {"type": "string", "enum": ["school", "hospital"]}}, "required": ["adcode", "facility_type"]}}}],
  "tool_choice": "auto",
  "temperature": 0.1
}

模型返回的 tool_calls 大致长这样(arguments 是字符串,不是对象,这点在解析时最容易踩):

{
  "choices": [{"message": {"role": "assistant", "content": null, "tool_calls": [{"id": "call_7f3a", "type": "function", "function": {"name": "stat_features", "arguments": "{"adcode":"330110","facility_type":"school"}"}}]}}]
}

宿主执行后回灌,模型才会生成自然语言答案:

{"role": "tool", "tool_call_id": "call_7f3a", "content": "{"ok":true,"count":186,"unit":"所","cache_hit":false}"}

坑:把 arguments 当对象用。OpenAI 兼容接口返回的是 JSON 字符串,部分模型会返回带尾逗号或前后空白的非法 JSON。解析必须 json.loads 加 try,失败当成 INVALID_PARAM 回灌给模型让它重发,而不是抛 500。

坑:把工具返回的原始要素数组直接塞回上下文。5000 条要素 × 每条 20 个字段,一轮就把上下文撑爆,后续模型开始丢指令。工具层必须做「摘要 + 引用」:返回前 5 条样例、总数、以及一个 result_ref 供前端按需取全量(见 4.5 节的返回体约定)。

4.2.4 记忆、黑板与多轮指代

多轮对话里「把刚才那个图层关掉」能不能听懂,取决于你有没有把动作结果写进状态。仅靠 messages 历史不够:历史是线性的,而地图状态是并发的(同时有 20 个图层、一个选择集、一个视口)。工程上用三块存储:

存储 内容 生命周期 用途
会话记忆 最近 N 轮 user/assistant 摘要 单会话 指代消解、避免重复问
任务黑板 当前任务的目标、已调工具、中间结果 id 单次请求 规划时读、执行时写、收敛时引用
结果缓存 查询结果(带 TTL 与参数 hash) 5~30 分钟 同一轮内复用、重试不重复查库

黑板是本节的关键。它至少记录四类字段:goal(用户要什么)、entities(已消解的行政区码/图层 id)、artifacts(已生成的图层、统计表、临时集合)、last_action(最近一次地图动作)。前端把当前图层树与视口作为 system 消息的一部分注入,指代才有落点:

# blackboard.py —— 任务黑板的最小结构,直接用 dataclass
from dataclasses import dataclass, field
from typing import Any

@dataclass
class Blackboard:
    goal: str = ""
    entities: dict[str, Any] = field(default_factory=dict)      # {"adcode": "330110"}
    artifacts: dict[str, Any] = field(default_factory=dict)     # {"stat_1": {"count": 186}}
    last_action: dict[str, Any] = field(default_factory=dict)   # {"tool": "map.zoom_to", "layer": "school"}
    steps: list[dict] = field(default_factory=list)             # 审计与回放

    def snapshot(self, max_chars: int = 1200) -> str:
        """给模型看的紧凑状态,宁可字段少也不要超长。"""
        text = f"goal={self.goal}; entities={self.entities}; last={self.last_action}"
        return text[:max_chars]

    def record(self, tool: str, args: dict, ok: bool, summary: str) -> None:
        self.steps.append({"tool": tool, "args": args, "ok": ok, "summary": summary})
        self.last_action = {"tool": tool, **args}

指代消解的兜底规则:如果模型在 entities 里找不到指代对象,不要让它猜。工具层提供一个 resolve_reference 工具,输入自然语言片段 + 候选列表,输出唯一的图层 id 或 AMBIGUOUS;模型拿到 AMBIGUOUS 就反问用户,而不是随便选一个。

4.2.5 失败恢复:修正一次,然后降级

工具失败分三类,处理策略完全不同。把三者混在一起重试,是最常见的烧钱方式。

失败类型 例子 策略 重试次数
参数可修正 图层名写错、枚举值拼错、日期格式不对 把错误码与合法值回灌,让模型改参 1
环境瞬时 数据库超时、上游 502、限流 指数退避重试同一参数 2(仅只读工具)
业务不可满足 数据不存在、越权、该区无此图层 停止重试,组织成「可选项」问用户 0
# recovery.py —— 三类失败的分派,别用一个 except 打天下
from enum import Enum

class FailKind(Enum):
    PARAM = "param"
    TRANSIENT = "transient"
    BUSINESS = "business"

def classify(code: str) -> FailKind:
    if code in ("INVALID_PARAM", "NOT_FOUND_LAYER"):
        return FailKind.PARAM
    if code in ("TIMEOUT", "UPSTREAM_ERROR", "RATE_LIMITED"):
        return FailKind.TRANSIENT
    return FailKind.BUSINESS            # FORBIDDEN / EMPTY_RESULT / UNSUPPORTED

def handle(code: str, attempt: int, readonly: bool) -> dict:
    kind = classify(code)
    if kind is FailKind.PARAM and attempt < 1:
        return {"action": "retry_with_feedback", "attempt": attempt + 1}
    if kind is FailKind.TRANSIENT and readonly and attempt < 2:
        return {"action": "retry_backoff", "delay_s": 2 ** attempt}
    return {"action": "degrade", "ask_user": True}

降级不是失败。把「这三个街道里有两个没有教育设施图层,要用 POI 数据替代吗?」抛给用户,比模型硬编一条假结论好得多。降级时前端要给出可点的选项,而不是只打一行错误。

坑:让模型自己决定重试次数。模型会无限重试同一参数(尤其 2B/4B 小模型,倾向复制上一次调用)。重试预算必须由宿主计数,超过就直接降级。

坑:把「空结果」当错误抛异常。用户问「这个街道有几所学校」,答案是 0 是合法回答。工具返回 {"ok": true, "count": 0},由模型组织成「该街道暂无登记学校」,不要返回 NOT_FOUND 触发无意义的重试。

4.3 模型与框架:Qwen3 与 Qwen-Agent

4.3.1 模型选型:先看工具调用稳定性,再看参数规模

一张图的工具集通常在 8~20 个之间,单次请求 3~8 轮调用。这个负载对模型的「指令遵循 + 结构化输出」要求远高于闲聊:参数少一个字段、枚举值多一个字符,整条链路就断。选型时优先看三项能力——工具调用格式稳定性、长上下文保持(要装得下图层表与几轮结果)、中文地名与行政编码识别。

Qwen3 系列(2025 年 4 月开源)分两档:0.6B/1.7B/4B/8B/14B/32B 的稠密模型,以及 30B-A3B、235B-A22B 的 MoE 模型;官方支持思考模式与非思考模式切换,并原生支持工具调用。工程上的实际结论如下表,规模数字只作为量级参考,最终以你自己的评测集为准。

模型档位 典型用途 工具调用稳定性 单卡部署 建议
0.6B / 1.7B 意图分类、槽位抽取 差(多工具时易漏字段) CPU 可跑 只做前置分类,不做编排
4B / 8B 固定 5~8 个工具的简单编排 中(需严格 schema + 示例) 单卡 16~24 GB 演示与内部试用可用
14B / 32B 10~20 个工具的多步编排 较好(仍需一次性修正兜底) 单卡 48~80 GB 或量化 生产主力档
30B-A3B 及以上 MoE / 云端大模型 复杂组合意图、需要解释 好 多卡或 API 对准确率负责的线上入口

选型落地的三条判断依据,比看榜单有用:

  1. 用你的 schema 测,不用通用榜单。把 4.5 节的 12 个工具定义原样喂给候选模型,跑 100 条真实问题,统计「工具名正确 + 必填参数齐全」的比例。小模型在工具数超过 10 时通常掉到 60% 以下。
  2. 看错误恢复能力。故意给一条错误参数,观察模型能否根据 hint 改对。稳定在第一次修正就改对的模型才适合上线。
  3. 看输出长度分布。同样任务下,模型 A 平均输出 120 token、模型 B 平均 400 token,成本差三倍。很多「效果差不多」的模型在这一点上差异巨大。

温度参数对工具调用的影响比对话大得多。实测口径:意图分类与参数生成用 temperature=0~0.1;结果解释与文案生成用 0.3~0.5。同一个会话里不要用同一个温度跑所有环节,工程上分两次调用比调参有效。

坑:用「能聊天」的模型直接做编排。一些对话质量很好的模型在工具调用上会输出「自然语言包 JSON」,即 好的,我将调用 stat_features,参数是 {...} 这种混合文本,解析器一崩全崩。上线前必须测「纯结构化输出率」。

坑:把模型换版本当成无痛升级。模型小版本升级会改变工具选择偏好,原本 5 步的链路可能变 3 步,也可能多调一次检索。每次换模型必须重跑 4.8 节的评测集并对比,不能直接滚。

4.3.2 Qwen-Agent:模型侧的 agent 框架

Qwen-Agent 是 Qwen 官方提供的 agent 框架,解决的是「怎么把模型、工具、消息循环组装成一个能跑的对象」。它的核心抽象只有四个:

概念 作用 本章怎么用
Assistant 封装 LLM + 工具列表 + 系统提示,提供 run 消息循环 智能体层的主对象
Function / 工具注册 声明工具名、描述、参数 schema 与执行体 4.5 节工具的实现基类
messages 标准 OpenAI 风格消息列表(user/assistant/tool) 黑板与审计的输入输出
model_server OpenAI 兼容端点,可指向本地 vLLM 或云端 换模型只改这个配置

最小可跑样例(需要先 pip install -U "qwen-agent[gui,rag,code_interpreter,mcp]",按需装 extras;网络与模型端点以实际环境为准):

# minimal_agent.py —— Qwen-Agent 最小闭环:一个工具 + 一个 Assistant
from qwen_agent.agents import Assistant

def stat_features(adcode: str, facility_type: str = "school") -> dict:
    """演示用:真实实现应查库并带白名单。"""
    demo = {"330110": {"school": 186, "hospital": 63}}
    return {"ok": True, "count": demo.get(adcode, {}).get(facility_type, 0)}

# 工具既可以写成本地函数,也可以用 tools 里的 Function 子类声明 schema
TOOLS = [{
    "name": "stat_features",
    "description": "按行政区代码与设施类型统计要素数量。adcode 为 6 位行政区代码,facility_type 取 school 或 hospital。",
    "parameters": {
        "type": "object",
        "properties": {
            "adcode": {"type": "string", "description": "6 位行政区代码,如 330110"},
            "facility_type": {"type": "string", "enum": ["school", "hospital"]}
        },
        "required": ["adcode", "facility_type"]
    }
}]

bot = Assistant(
    llm={
        "model": "qwen3-32b",
        "model_server": "http://127.0.0.1:8000/v1",   # vLLM / DashScope 兼容端点
        "api_key": "EMPTY",
        "generate_cfg": {"temperature": 0.1, "max_tokens": 1024},
    },
    function_list=TOOLS,
    system_message="你是地图助手。只能调用给定工具,不要编造数据;缺参数时先追问。",
)

messages = [{"role": "user", "content": "余杭区有多少所学校?"}]
for chunk in bot.run(messages=messages):      # 流式输出,最后一项是完整消息列表
    final = chunk

print(final[-1]["content"])

注意 function_list 接受三种形态:纯字符串(引用框架内置工具,如代码解释器)、函数对象(框架反射参数)、以及 MCP 配置。生产代码里推荐「schema 单独定义 + 执行体单独注册」:schema 要进评测与版本管理,执行体要接权限与审计,两者生命周期不同。工具注册表本身很简单——一个字典存 schema、一个字典存实现,执行前先按 schema 校验参数(校验库用 jsonschema,见 4.4 节的 additionalProperties 讨论)。

4.3.3 分工:Qwen-Agent 跑 agent,DSH 写代码与跑实验

这两个东西经常被混为一谈,其实层次不同:

维度 Qwen-Agent DeepSeek Harness(DSH)
定位 模型侧的 agent 框架(库) 开发与运行 agent 系统的 harness(工具)
运行时角色 被你的服务 import,随服务进程跑 开发机/CI 上的智能体,读写工作区、执行命令
输入 用户消息 + 工具列表 一个任务书(自然语言 + 验收标准)
输出 工具调用与最终回答 代码、测试、评测报告、修复
权限 由你的服务控制 sandbox(read-only/workspace-write/danger-full-access)+ approval

本系列的做法很明确:线上服务用 Qwen3 + Qwen-Agent(或任意 OpenAI 兼容栈),把写工具代码、造评测集、跑回归、修失败样本这些活交给 DSH。DSH 在命令行里以 dsh 启动,Web UI 用 dsh web(默认只监听回环地址,实际 URL 以启动时打印为准),批量回归用 headless:

# 在一个干净的工作区里让 DSH 生成工具骨架并自查
mkdir -p ~/work/onemap-agent && cd ~/work/onemap-agent
dsh --profile headless "读取 tools.schema.json,为每个工具生成 FastAPI 路由骨架与 pytest 用例;要求:只读工具不接受写参数,所有返回体使用 {ok, data, code, hint} 结构。生成后运行 pytest 并修复失败用例。" --permission-mode workspace-write
# 批量跑评测集,输出机器可读事件流,落 CSV 后再对比版本
dsh --profile headless "运行 scripts/eval_agent.py --set eval/golden_v3.jsonl --model qwen3-32b --out runs/v3.csv,只允许读工作区与调用本地 8000 端口的评测服务;跑完汇总五个指标写入 runs/v3.summary.md" --json > runs/v3.events.jsonl

把评测流程固化成技能(skill)是 DSH 的常规用法:在 $DSH_HOME/skills 下建目录,写一个 SKILL.md 描述「跑评测 → 汇总 → 定位失败样本 → 归类失败原因」,之后在会话里按需加载,不用每次重述流程。这样评测的门槛从「记得所有命令」降到「说一句跑评测」。

坑:在 DSH 的任务书里写「帮我优化一下智能体」。这类任务没有验收标准,DSH 只能猜。任务书必须给:输入文件、要改的范围、必须通过的测试、以及「不许改动的文件」清单,否则它可能顺手重构你的 schema。

坑:把模型的 API key 写进任务书或代码。DSH 的会话与代码都会留痕,凭据走环境变量(如 DEEPSEEK_API_KEY)或 $DSH_HOME 下的凭据配置,评测脚本只读 os.environ。

4.3.4 让模型稳定输出:四个旋钮

工具调用的稳定性有一半不在模型,而在你怎么约束它。四个旋钮按优先级排:

旋钮 做法 效果 注意
工具描述规范化 统一句式:用途 + 何时用 + 何时不用 + 参数说明 最高 见 4.4.1 模板
参数 schema 收紧 加 enum、pattern、minimum/maximum、required 高 不要留「自由文本」参数
解码参数 工具调用轮 temperature=0.1、top_p=0.9 中 别用 temperature=0 做多轮,易死循环
输出约束 优先用厂商的 Function Calling 通道;若只能文本解析,加 JSON mode / response_format 中 不同模型/网关支持度不一,先探测

系统提示也要给「负面清单」,这比正面描述更能压住乱调:

你是「一张图」助手。工作规则:
1. 只允许调用 tools 中列出的工具;用户提到未注册的地名或图层时,先调用 search_layers 确认,不要臆造 id。
2. 涉及写操作(创建图层、保存专题、派单)前必须复述参数并等待用户确认。
3. 一次回答最多调用 6 次工具;仍无法完成时,列出已确认的事实与待确认项,请用户选择。
4. 不要输出 SQL、NoSQL 查询、SLD 或任何可直接执行的原生查询文本。
5. 回答必须引用工具返回的数字与字段,不得自行推算或补全缺失值。

坑:把「不要编造」写十遍。约束要靠机制不靠措辞:±范围用 enum 限定、地名用 resolve_place 工具解析、数字只允许从工具返回体引用。提示词只负责说明「为什么」。

坑:给模型一个 query_anything(sql) 式的万能工具。图省事的结果是模型开始写 SELECT *,注入与越权风险全部外移给数据库。宁可 20 个窄工具,也不要 1 个宽工具。

4.3.5 模型分工:编排、路由、视觉三个角色

一张图的工具集有 8~20 个、单请求 3~8 轮,这个负载不适合「一个模型打通全链路」。工程上按三档分工:便宜的模型做高频判断,贵的模型做低频编排,专用模型做视觉。三档的边界不按参数量划,而按「这一步错了会不会毁掉整条链路」划。

角色 干什么 2026 可选档位 关键约束 为什么不能换给别档
编排模型 多步规划、跨工具参数绑定、把结果组织成结论 DeepSeek V4-Pro(约 1.6T 总参 / 49B 激活 / 1M 上下文) 工具调用格式稳定性、长上下文保持 编排错一步整条链路作废,轮数与参数正确率都靠它;私有化场景换 Qwen3-32B 或 30B-A3B
路由模型 意图分类、槽位抽取、单工具直呼、兜底短指令 DeepSeek V4-Flash(约 284B / 13B 激活 / 1M 上下文);小部署用 Qwen3-4B/8B 首 token 延迟、单次成本、并发 它要吃 60~80% 的请求量,用编排档跑等于把成本乘几倍
视觉模型 航拍切片识别、专题图读图、截图定位、图例文字提取 Qwen3-VL(2B/4B 可单卡部署) 分辨率、中文标注、输出可结构化 编排模型不做像素级判断;视觉模型也不该直接产出最终参数

落到部署形态有三种,选哪种由数据能不能出内网决定:

  1. 云端 API 路线:编排 V4-Pro + 路由 V4-Flash,两个 key 分开配额、分开限流;视觉走自建 Qwen3-VL 的 OpenAI 兼容端点。
  2. 私有化路线:编排与路由都用 Qwen3-32B(或 235B-A22B MoE),单卡 48~80 GB 或量化后部署;能力下降用更严的 schema、更窄的工具集补。
  3. 混合路线(行业项目最常见):涉密数据不出内网的部分走本地 Qwen,通用意图与文案走 V4-Flash;但编排只留在一个模型上,跨模型来回切会改变工具选择偏好,4.8 的评测必须按组合跑,而不是单模型跑。

1M 上下文怎么用也要有纪律:它可以一次塞进 50~200 条评测样本的 trace 做失败归因批处理,也可以把 300 图层的目录压缩后整体给编排模型看;但别当无限记忆用——上下文越长,最近一轮的工具结果被稀释得越厉害,工具返回体仍按 4.4.2 的 4 KB 阈值裁剪。

# route_then_plan.py —— 先路由后编排,两个档位分开配置与限流
from openai import OpenAI

client = OpenAI(api_key="从环境变量读取", base_url="https://api.deepseek.com")
ROUTER_MODEL = "deepseek-v4-flash"    # 高频、便宜、只做判断
PLANNER_MODEL = "deepseek-v4-pro"     # 低频、贵、对准确率负责
INTENTS = {"map", "query", "analysis", "render", "write"}

def route(text: str) -> str:
    """路由档:短提示、固定枚举、temperature=0,只让它输出一个词。"""
    resp = client.chat.completions.create(
        model=ROUTER_MODEL,
        temperature=0,
        max_tokens=8,
        messages=[
            {"role": "system",
             "content": "把请求归到 map/query/analysis/render/write 之一,只输出类别名,不要解释。"},
            {"role": "user", "content": text},
        ],
    )
    label = (resp.choices[0].message.content or "").strip()
    return label if label in INTENTS else "write"    # 判不出来时按最谨慎的档处理

def plan(text: str, tool_schemas: list[dict]) -> object:
    """编排档:带完整工具定义,温度 0.1,允许长上下文。"""
    return client.chat.completions.create(
        model=PLANNER_MODEL,
        temperature=0.1,
        tools=tool_schemas,
        tool_choice="auto",
        messages=[
            {"role": "system", "content": "你是地图编排器,只能调用给定工具。"},
            {"role": "user", "content": text},
        ],
    )

if __name__ == "__main__":
    text = "把这三个街道的学校按密度分色,再叠加医院"
    intent = route(text)                       # 先花一次便宜调用
    print("intent:", intent)
    if intent != "write":                      # 写操作不进自动编排,走 4.5.4 的确认流程
        resp = plan(text, TOOL_SCHEMAS)        # TOOL_SCHEMAS 按 4.4.1 的规范生成
        print(resp.choices[0].message.tool_calls)

坑:用同一个大模型跑所有环节。意图分类占请求量的大头,用它跑分类等于把成本乘几倍、把延迟拉高一个数量级。路由与编排的模型档位必须独立配置,独立限流。

坑:把「换更贵的模型」当成优化手段。工具描述含糊时,换到编排档只会让错误更自信。先按 4.4.2 的阈值改工具,再考虑升档;升档必须重跑 4.8 的评测集,工具数超过 12 个才是升档的典型信号。

坑:把视觉模型的自然语言描述直接当参数用。视觉模型给的是「左侧有一处施工围挡」这种描述,不能直接进 zoom_to 的 bbox。要么让它按固定 JSON 输出并做 schema 校验,要么交给编排模型做一次结构化绑定。

4.4 Function Calling 与 MCP

4.4.1 工具定义规范:name、description、parameters

一个工具定义写得好不好,直接决定模型会不会用错。本章统一三条硬规范:

  1. name 用「动词_名词」,全小写加下划线,最长 32 字符。不要用 query、data 这种没有语义的词,也不要一个名字干三件事。
  2. description 回答四个问题:这个工具做什么、什么情况下用、什么情况下不要用、返回什么。第二和第三点最容易被省略,而模型的误调几乎都出在这里。
  3. parameters 用 JSON Schema 收紧:字符串给 enum 或 pattern,数字给 minimum/maximum,能固定的就别留自由文本,必填字段全部进 required。

下面是「查询行政区人口」的完整定义,可以直接放进 tools 数组:

{
  "type": "function",
  "function": {
    "name": "query_population",
    "description": "查询指定行政区在指定年份的常住人口。仅用于人口数量查询;如需人口密度请先取面积再计算,不要用本工具估算。数据来自年度统计口径,缺失年份会返回 EMPTY_RESULT。",
    "parameters": {
      "type": "object",
      "properties": {
        "adcode": {
          "type": "string",
          "pattern": "^[0-9]{6}$",
          "description": "6 位行政区代码,如 330110。区县名请先用 resolve_place 转成代码。"
        },
        "year": {
          "type": "integer",
          "minimum": 2000,
          "maximum": 2024,
          "description": "统计年份;不传默认 2023。"
        },
        "metric": {
          "type": "string",
          "enum": ["resident", "registered"],
          "description": "resident 常住人口,registered 户籍人口;默认 resident。"
        }
      },
      "required": ["adcode"],
      "additionalProperties": false
    }
  }
}

模型发起调用后,宿主执行并回灌下面的返回体。注意返回体是给模型看的,不是给前端看的:字段少、单位明确、带 code 与 hint,模型才知道下一步做什么。

{
  "ok": true,
  "data": {
    "adcode": "330110",
    "name": "余杭区",
    "year": 2023,
    "metric": "resident",
    "value": 131.2,
    "unit": "万人",
    "source": "2023 年统计年鉴"
  },
  "message": ""
}

失败时必须让模型「读得懂、改得动」:

{"ok": false, "code": "INVALID_PARAM", "message": "adcode 必须是 6 位数字", "hint": "你传入的是 '余杭区',请先调用 resolve_place 获取代码", "retryable": true}
{"ok": false, "code": "EMPTY_RESULT", "message": "2024 年余杭区人口数据尚未发布", "hint": "可用年份为 2000-2023,请改用 2023 或更早年份", "retryable": true}

对比一下两种返回写法的实际后果:

返回体写法 模型下一步行为 结论
{"error": "KeyError: 'pop'"} 原样重试或直接放弃 不可用,堆栈对模型无意义
{"ok":false,"code":"EMPTY_RESULT","hint":"可用年份 2000-2023"} 改年份重试一次,成功 可用,这就是标准

坑:additionalProperties: false 写了但没在宿主侧校验。模型可能仍输出多余字段,执行函数收到意外关键字参数直接抛 TypeError。宿主侧必须先用 jsonschema 校验,再 **kwargs 调用。

坑:description 写成 API 文档(罗列实现细节、数据库集合名)。模型只需要「何时用」,实现细节会诱发它编造参数。集合名、表名只出现在工具实现内部的白名单里。

4.4.2 工具粒度三原则

工具粒度是整章最需要经验的地方。切太细,模型要调 20 次;切太粗,参数变成「自由发挥」。三条可执行的原则:

原则一:一个工具对应一个「用户可理解的动词」。 用户能说出「查一下」「算一下缓冲区」「按人口分级着色」,这三个就是三个工具。用户不会说「执行一个带过滤条件的聚合管道」,所以不要有 run_pipeline。

原则二:单次工具调用不跨两个决策。 如果工具内部需要模型先做一次判断(比如「如果图层存在就更新,否则创建」),说明应该拆成 get_layer + update_layer,让模型显式决策,也让审计日志能看出来它选了哪个。

原则三:参数总数 ≤ 6,必填 ≤ 3。 超过就考虑拆工具或提供预设。对比数据:同一套评测集下,参数从 8 个降到 4 个,参数正确率通常能提升 10~20 个百分点(具体数字以你的 4.8 节评测为准)。

三条原则之外,再用四个数把「切得对不对」量化。任一超阈值都是工具设计有问题的信号,先改工具,不要先换模型。

判据 阈值 超了怎么办 怎么测
单次任务期望调用轮数 ≤ 5(硬上限 8) 粒度太细,把「检索 + 读取」合并成一个工具 按评测集 expect_tools 长度排序,看超长样本差在哪一步
单工具参数个数 推荐 ≤ 6,硬上限 8 拆工具,或把高频参数组合封装成 preset 数 schema 的 properties 数量,放进 CI 断言
必填参数个数 ≤ 3 能从会话态推出的(org_id、当前视口、用户单位)改由服务端注入 同上,CI 断言 required 长度
返回体大小 ≤ 4 KB 摘要 + 句柄 裁样例条数,全量走 result_ref 或分页 统计 len(json.dumps(result)),超 4096 字节告警

阈值都有来历,不是拍脑袋:每轮 tool 消息按 1~3 KB 计,5 轮就吃掉 5~15 KB 上下文,再加系统提示与图层表,预算很快见底;参数个数卡 8 是因为属性越多漏字段概率越高,从 4 加到 10 时必填字段缺失率会明显上升;4 KB 是「5 条样例 + 总数 + 句柄」装得下的经验上限,超过这个数的返回基本等于把数据库塞给模型。三条阈值都能在 CI 里用脚本断言,工具链见 4.8.1 的「2026 工具链」一列。

原则与阈值是同一件事的两面:原则回答「怎么切」,阈值回答「切完怎么验收」。返回体超限后的句柄与分页写法见 4.5.5。

反例 问题 改法
do_map(action, params) action 是自由字符串,模型在里面塞 SQL 拆成 zoom_to / toggle_layer / add_layer
query_features(collection, query) query 让模型写原生查询 改为 {layer_id, where: {field, op, value}, bbox, limit}
analysis(kind, data) kind 有 20 个值,data 无 schema 按分析类型拆成 buffer / overlay / stat_by_polygon

4.4.3 MCP:为什么它是 GIS 能力的合适容器

MCP(Model Context Protocol)是一个开放协议,把「模型可调用的能力」从某个具体应用里解耦出来。写代码前先对齐规范版本:以 MCP 2026-07-28 规范为准,它的核心变化有三条,都会直接影响你的 server 怎么写、怎么部署。

变化 2026-07-28 规范口径 旧口径(HTTP+SSE 过渡形态) 对 GIS server 的影响
会话模型 stateless 核心:服务端不再依赖长连接里的会话状态,每个请求自带上下文 依赖 SSE 长连接维持会话,断开即丢 可以上无状态副本、水平扩容,不做 sticky session;图层目录、结果句柄等状态放 Redis 或数据库
传输 Streamable HTTP:单一端点,POST 请求携带 MCP-Protocol-Version 等协议版本头,按需以流式响应回推 旧的 HTTP+SSE 双端点(先 GET 建流再 POST 发消息),属过渡形态 走标准网关/反代即可,鉴权与限流按普通 HTTP 服务做,不必为 SSE 单独开长连接通道
能力面 tools(可调用)/ resources(可读上下文)/ prompts(提示模板)三类语义分开;另有 MCP Apps 方向(把可交互界面也作为能力单元) 主要围绕 tools 图层目录适合放 resources(模型按需读取,不占每次调用的工具位),操作类放 tools,常见问法的提示模板放 prompts

三个角色(Host / Client / Server)没变,变的是连接的生命周期:

┌──────────┐   1 建立连接与能力协商   ┌────────────┐   2 转发调用   ┌────────────┐
│   Host   │ ───────────────────────▶ │   Client   │ ────────────▶ │   Server   │
│ DSH / 你  │ ◀─────────────────────── │ (1:1 连接)  │ ◀──────────── │ 地图服务    │
│ 的智能体  │   4 结果回灌给模型        └────────────┘   3 执行工具   └────────────┘
└──────────┘
     ▲ 能力:tools(可调用)/ resources(可读上下文)/ prompts(提示模板)
     ▲ 传输:stdio(本地子进程)/ Streamable HTTP(远程服务,单端点 + 协议版本头)
     ▲ 会话:2026-07-28 规范下为 stateless 核心,请求自带上下文

对 GIS 团队来说,MCP 的价值不在「新」,而在一次封装、多处复用:把「查图层、算缓冲区、取要素、做分级统计」封装成一个 MCP server 之后,DSH 可以用,你自己的 Web 智能体可以用,第三方的地图客户端也能用。这比每个客户端各写一套私有插件接口省事得多。

                     ┌──────────────────────────────┐
                     │   onemap-gis MCP server       │
                     │  tools / resources / prompts  │
                     │  ├ search_layers   ├ buffer   │
                     │  ├ stat_by_polygon ├ render   │
                     │  └ 白名单 + 只读账号 + 审计     │
                     └──────────────┬───────────────┘
               stdio / Streamable HTTP(协议版本头)
        ┌───────────────────┼───────────────────┐
        ▼                   ▼                   ▼
┌───────────────┐   ┌───────────────┐   ┌───────────────┐
│ DSH(host)    │   │ 自有 Web 智能体 │   │ 第三方地图客户端│
│ 内置工具同屏   │   │ 直接挂同一 server│   │ 配置即接入      │
└───────────────┘   └───────────────┘   └───────────────┘
  一次封装 → 三处复用;权限与审计只在 server 侧实现一次

这张图是本章全部 MCP 内容的主张:能力在 server 里,策略在 server 里,客户端只做配置。所以下面 4.4.4 的骨架、4.4.5 的接入、4.4.6 的远程部署都以「server 自治」为前提——任何需要在客户端补的权限,都不算真正收口。

方案 复用范围 接入成本 权限控制
私有 REST 接口 仅本项目 每个客户端写一套适配 靠网关与 token
私有插件协议 同一框架内 绑定框架版本 框架内配置
MCP server 任意 MCP host 写一次,配置即接入 服务端自校验 + host 侧 sandbox

4.4.4 把 GIS 能力封成 MCP server

下面是一个最小 server,注册两个工具:查图层、算缓冲区(缓冲区用近似平面计算,生产环境应换成 PostGIS 的 ST_Buffer 或 GEOS)。基于官方 Python SDK 的 FastMCP,stdio 传输:

# onemap_mcp/server.py —— 最小 GIS MCP server(stdio)
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field

mcp = FastMCP("onemap-gis")

# 演示数据;真实实现接图层目录库与空间数据库
LAYERS = {
    "school_2023": {"name": "教育设施(2023)", "geom": "point", "count": 186},
    "hospital_2023": {"name": "医疗机构(2023)", "geom": "point", "count": 63},
}

class LayerQuery(BaseModel):
    keyword: str = Field(description="图层名称关键字,如 教育、医院")
    limit: int = Field(default=5, ge=1, le=20, description="最多返回条数")

class BufferReq(BaseModel):
    layer_id: str = Field(description="图层 id,须先通过 search_layers 获得")
    distance_m: int = Field(ge=1, le=5000, description="缓冲距离,单位米,上限 5000")

@mcp.tool()
def search_layers(q: LayerQuery) -> dict:
    """按关键字检索图层目录。找不到时返回 EMPTY_RESULT,不要据此臆造图层 id。"""
    hits = [{"layer_id": k, **v} for k, v in LAYERS.items() if q.keyword in v["name"]]
    if not hits:
        return {"ok": False, "code": "EMPTY_RESULT", "hint": f"没有名称含「{q.keyword}」的图层"}
    return {"ok": True, "data": hits[: q.limit], "total": len(hits)}

@mcp.tool()
def buffer_layer(req: BufferReq) -> dict:
    """对指定图层做缓冲区分析,返回临时图层 id 与要素数。只读工具,不修改源数据。"""
    if req.layer_id not in LAYERS:
        return {"ok": False, "code": "NOT_FOUND", "hint": "先调用 search_layers 获取合法 layer_id"}
    return {"ok": True, "data": {"result_layer": f"{req.layer_id}__buf{req.distance_m}",
                                 "distance_m": req.distance_m, "count": LAYERS[req.layer_id]["count"]}}

if __name__ == "__main__":
    mcp.run(transport="stdio")     # 远程部署时改用 streamable HTTP

用官方检查器先验证再接入,比直接塞给 host 排错快:

# 依赖:pip install "mcp[cli]" pydantic
# 1) 本地起服务做协议握手与工具枚举(stdio)
npx -y @modelcontextprotocol/inspector python onemap_mcp/server.py
# 2) 冒烟:确认两个工具都在 tools 列表里,且参数 schema 带 description
# 3) 接入你的 host 之后,先问一句「有哪些图层」验证读链路,再试写操作

4.4.5 DSH 侧接入 MCP 与权限收口

DSH 作为 host,把 MCP server 的工具与内置工具放在同一个调用面上:模型看到的是统一的工具列表,宿主负责路由到对应的 server。接入方式是在 DSH 的 MCP 配置里登记 server 的启动命令与传输方式(具体字段以当前版本界面或 dsh --help 为准,不要凭记忆写配置)。

接完必须做权限收口,否则「给 agent 能力」等于「给 agent 你的数据库账号」:

收口点 做法 验证方式
数据集 server 端硬编码可选集合/图层白名单 传一个不存在的集合,应返回 FORBIDDEN
只读 server 用只读数据库账号;写工具单独一个 server 检查账号权限与 server 进程的 argv
行数/体积 所有列表接口强制 limit,默认 20、上限 200 传 limit=100000 应被 clamp
超时 server 内每个工具设 timeout,慢查询直接 TIMEOUT 人为注入 5s 延迟测降级
审计 记录 call_id、工具名、参数摘要、耗时、结果码 抽查日志能还原一次请求
宿主权限 DSH 侧仍受 sandbox/approval 约束,扫描目录仍可能被拒 让它读工作区外的路径应失败

坑:MCP server 写成「什么都能查」的万能网关。协议只是传输约定,不提供任何权限。只要工具能接受任意 SQL/collection 名,注入面就还在。白名单必须在 server 内部,不能靠 host 提示词约束。

坑:远程用 streamable HTTP 却不做鉴权,直接暴露在公网。stdio 是本地子进程,HTTP 是有网络攻击面的服务。至少上 token + 反向代理 + 限流,并单独部署一个只读实例给智能体用。

4.4.6 远程部署:Streamable HTTP 与版本协商

stdio 只适合本地:DSH 在自己机器上拉起子进程,一切正常。一旦要把 server 放到服务器上给多个 host 共用,就必须换成 Streamable HTTP,并按 2026-07-28 规范处理三件事:单端点、协议版本头、无状态。

# onemap_mcp/http_server.py —— 单端点 Streamable HTTP,按协议版本头分流
from mcp.server.fastmcp import FastMCP
from starlette.applications import Starlette
from starlette.responses import JSONResponse

mcp = FastMCP("onemap-gis")
SUPPORTED = {"2026-07-28"}          # 以官方规范文档的版本号为准,不支持就往上报错

# 工具注册与 4.4.4 一致,此处省略

async def version_guard(request, call_next):
    """协议版本头缺失或不受支持时明确拒绝,不要让客户端猜。"""
    version = request.headers.get("mcp-protocol-version", "")
    if version and version not in SUPPORTED:
        return JSONResponse(
            {"error": "UNSUPPORTED_PROTOCOL_VERSION",
             "supported": sorted(SUPPORTED),
             "hint": "升级客户端或 server,不要静默降级"},
            status_code=400,
        )
    return await call_next(request)

app = mcp.streamable_http_app()      # 单端点;具体方法名以当前 Python SDK 版本为准
app.add_middleware(version_guard)

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8765)   # 生产必须放在反代后面加鉴权

远程部署的四个必做项,缺一个都会在接入后一周内变成事故:

项 做法 不做的后果
鉴权 网关层 token / mTLS,server 校验调用方身份并映射到只读账号 公网裸奔,等同于把空间库开放查询
限流 按 client 维度限 QPS 与并发;慢查询单独设阈值 一个 host 的批量回放把服务打满
无状态 会话相关数据(分页游标、结果句柄)放 Redis,带 TTL 加副本后请求打到别的实例,句柄失效
版本 请求头校验协议版本;server 记录自身版本到 health 接口 客户端升级后静默失败,排查耗掉半天

坑:把 stdio 的 server 用 socat 之类硬转成网络服务。stdio 的信任模型是「本地子进程」,转成网络后身份校验、超时、并发全都没了。远程就用 Streamable HTTP 重写入口,工具实现可以原样复用。

坑:按旧版 HTTP+SSE 双端点实现,再指望新客户端兼容。2026-07-28 规范是单端点 + 协议版本头,旧形态只是过渡;两套并存会让你的一半 bug 出在「连上了但流没起来」。

坑:把「无状态」理解成「不用存任何东西」。stateless 指的是服务端不依赖长连接上下文,result_ref 句柄、评测结果、审计日志当然要存,只是要存在外部存储里并带 TTL,而不是塞在进程内存的字典里。

4.5 自定义一张图 FC 工具

4.5.1 工具分层:四类工具,四套权限

不要把所有能力塞进一个文件。按「能不能写、碰不碰数据」两个维度分成四类,每类的权限模型不同:

层 工具类 典型工具 读/写 权限要求
数据访问 MongoDBTool count_features / query_features / aggregate_by_polygon 只读 集合白名单、只读账号、行数上限
地图操作 MapTool toggle_layer / zoom_to / highlight_features 会话态写 只改前端状态,不落库,可撤销
分析 AnalysisTool buffer / overlay / stat_by_polygon 产生临时结果 计算资源限额、超时、结果 TTL
渲染 ThemeTool set_render_rule / set_legend 会话态写 只接受结构化规则,禁止 SLD 文本

分层的收益是权限可以按类收:数据访问类的工具共用一套集合白名单与只读连接池,地图与渲染类完全不碰数据库,分析类只跑受控算子。审计日志也按类打标签,出问题时一眼能看出是哪层越界。

4.5.2 MongoDBTool:结构化查询,禁止原生查询

这个工具类的核心原则一句话:模型只能传结构化参数,原生 filter 由代码拼装。 让模型写 {"$where": ...} 或者直接拼字符串,注入和慢查询就都来了。

先定返回体与错误码(全章所有工具共用这一套):

# result.py —— 统一返回体与错误码,所有工具都返回这个结构
from typing import Any

CODES = {
    "INVALID_PARAM": "参数不合法,按 hint 修正后重试",
    "NOT_FOUND": "对象不存在,先检索再调用",
    "FORBIDDEN": "越权访问,不要重试,换用可用范围",
    "TIMEOUT": "查询超时,缩小范围或降低 limit",
    "UPSTREAM_ERROR": "上游服务异常,可重试一次",
    "EMPTY_RESULT": "查询成功但无数据,属正常结果",
}

def fail(code: str, hint: str = "", retryable: bool = False) -> dict:
    return {"ok": False, "code": code, "message": CODES.get(code, code),
            "hint": hint, "retryable": retryable}

def ok(data: Any, message: str = "") -> dict:
    return {"ok": True, "data": data, "message": message}
# mongodb_tool.py —— 字段白名单 + 结构化过滤 + 分页 + 超时
import re
from pymongo import MongoClient
from pymongo.errors import PyMongoError, ExecutionTimeout
from result import ok, fail

ALLOWED_COLLECTIONS = {"edu_facility", "medical_facility", "admin_boundary"}
FIELD_WHITELIST = {
    "edu_facility":    {"name", "adcode", "type", "students", "geom"},
    "medical_facility": {"name", "adcode", "level", "beds", "geom"},
    "admin_boundary":  {"name", "adcode", "area_km2", "geom"},
}
OPS = {"eq": "$eq", "ne": "$ne", "gt": "$gt", "gte": "$gte", "lt": "$lt", "lte": "$lte", "in": "$in"}
MAX_LIMIT = 200
PROJECTION_ALWAYS = {"_id": 0}

class MongoDBTool:
    def __init__(self, uri: str, db: str = "onemap") -> None:
        self.client = MongoClient(uri, serverSelectionTimeoutMS=3000, readPreference="secondaryPreferred")
        self.db = self.client[db]

    def _check(self, collection: str, field: str | None = None) -> str | None:
        if collection not in ALLOWED_COLLECTIONS:
            return f"集合 {collection} 不在白名单,可选:{sorted(ALLOWED_COLLECTIONS)}"
        if field is not None and field not in FIELD_WHITELIST[collection]:
            return f"字段 {field} 不可查,可选:{sorted(FIELD_WHITELIST[collection])}"
        return None

    def _build_filter(self, collection: str, conds: list[dict]) -> dict:
        query: dict = {}
        for c in conds:
            field, op, value = c.get("field"), c.get("op", "eq"), c.get("value")
            if (err := self._check(collection, field)) is not None:
                raise ValueError(err)
            query.setdefault(field, {}).setdefault(OPS[op], value)
        return query

    def query_features(self, collection: str, conds: list[dict] | None = None,
                       fields: list[str] | None = None, limit: int = 20) -> dict:
        if (err := self._check(collection)) is not None:
            return fail("INVALID_PARAM", err)
        limit = max(1, min(int(limit), MAX_LIMIT))
        projection = dict(PROJECTION_ALWAYS)
        for f in (fields or []):
            if self._check(collection, f) is not None:
                return fail("INVALID_PARAM", f"字段 {f} 不可返回")
            projection[f] = 1
        try:
            query = self._build_filter(collection, conds or [])
            cursor = (self.db[collection].find(query, projection)
                      .limit(limit).max_time_ms(2000))
            rows = list(cursor)
        except ExecutionTimeout:
            return fail("TIMEOUT", "缩小 bbox 或减少条件后重试", retryable=True)
        except (ValueError, PyMongoError) as exc:
            return fail("UPSTREAM_ERROR", f"查询失败:{exc}", retryable=True)
        if not rows:
            return ok({"items": [], "total": 0}, "无匹配要素,属正常空结果")
        geom_free = [{k: v for k, v in r.items() if k != "geom"} for r in rows]
        return ok({"items": geom_free[:5], "total": len(rows),
                   "result_ref": f"mongo:{collection}:{abs(hash(str(query))) % 10**8}",
                   "truncated": len(rows) == limit})

对应的工具 schema 只暴露结构化参数,注意 conds 里 field 用 enum 限定、op 固定枚举,模型没有机会写原生查询:

{
  "type": "function",
  "function": {
    "name": "query_features",
    "description": "按结构化条件查询图层要素,返回最多 5 条样例、总数与 result_ref。用于获取名称/编码/数值等属性;不要用它做空间范围筛选(用 query_in_bbox)。",
    "parameters": {
      "type": "object",
      "properties": {
        "collection": {"type": "string", "enum": ["edu_facility", "medical_facility", "admin_boundary"]},
        "conds": {
          "type": "array",
          "maxItems": 4,
          "items": {
            "type": "object",
            "properties": {
              "field": {"type": "string", "enum": ["name", "adcode", "type", "students", "level", "beds", "area_km2"]},
              "op": {"type": "string", "enum": ["eq", "ne", "gt", "gte", "lt", "lte", "in"]},
              "value": {"description": "标量值;op=in 时传数组,长度不超过 20"}
            },
            "required": ["field", "op", "value"],
            "additionalProperties": false
          }
        },
        "fields": {"type": "array", "items": {"type": "string"}, "maxItems": 8},
        "limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 20}
      },
      "required": ["collection"],
      "additionalProperties": false
    }
  }
}

三个细节决定这个工具能不能长期安全运行:查询超时用 max_time_ms 强制(不要只靠客户端 timeout);返回体强制剥离 geom(几何 WKB 又大又不可读,模型要的是属性),几何通过 result_ref 交给前端取;limit 在服务端再 clamp 一次,不信任模型传值。

坑:limit 只在 schema 里写 maximum: 200,服务端不校验。模型(或直接打接口的攻击者)传 100000,数据库照单全收。schema 是给模型的提示,不是安全边界,服务端必须重复校验。

坑:把 result_ref 设计成可猜测的字符串(如 collection:1)。前端按 ref 取全量时能遍历别人的结果。ref 应该是带签名或随机 UUID 的服务端 token,且有 TTL。

4.5.3 MapTool:只改前端状态

地图操作的共同点是「不落库、可撤销、幂等」。所以它不需要数据库权限,只需一个会话级的地图状态机。每个工具的参数与返回值:

工具 参数 返回 幂等
toggle_layer layer_id、visible: bool {ok, data:{layer_id, visible}} 是
zoom_to bbox [minx,miny,maxx,maxy] 或 layer_id、padding: int {ok, data:{bbox, zoom}} 是
highlight_features layer_id、ids: string[](≤ 50)、style_key {ok, data:{count}} 是
add_temp_layer geojson_ref、name、style {ok, data:{layer_id}} 否(每次新 id)
clear_temp_layers 无 {ok, data:{removed}} 是
# map_tool.py —— 地图状态指令,返回"前端该做什么"而不是"图已经变了"
import uuid
from result import ok, fail

class MapTool:
    def __init__(self, layer_catalog: dict[str, dict]) -> None:
        self.catalog = layer_catalog
        self.temp_layers: dict[str, dict] = {}

    def toggle_layer(self, layer_id: str, visible: bool) -> dict:
        if layer_id not in self.catalog:
            return fail("NOT_FOUND", f"图层 {layer_id} 不存在,先调 search_layers")
        return ok({"layer_id": layer_id, "visible": bool(visible)})

    def zoom_to(self, bbox: list[float] | None = None, layer_id: str | None = None,
                padding: int = 40) -> dict:
        if bbox is None and layer_id is None:
            return fail("INVALID_PARAM", "bbox 与 layer_id 至少给一个")
        if bbox is not None:
            if len(bbox) != 4 or bbox[0] >= bbox[2] or bbox[1] >= bbox[3]:
                return fail("INVALID_PARAM", "bbox 须为 [minx,miny,maxx,maxy] 且 min < max")
        return ok({"bbox": bbox, "layer_id": layer_id, "padding": padding})

    def add_temp_layer(self, geojson_ref: str, name: str, style: dict | None = None) -> dict:
        lid = f"tmp_{uuid.uuid4().hex[:8]}"
        self.temp_layers[lid] = {"name": name[:40], "style": style or {}, "ref": geojson_ref}
        return ok({"layer_id": lid, "ttl_minutes": 30})

注意 MapTool 返回的是指令,不是执行结果。真正改地图的是前端:后端把「用户可读的动作」写进响应的 map_actions 数组,前端 reducer 按序执行(协议见 4.6.3)。这样做的好处是后端无状态、可回放,同一个 trace 重放能复现同一张图。

坑:让后端直接调地图服务的 REST 改图层可见性。地图渲染状态在前端,后端改的是另一份状态,两边不同步,用户会看到「说了要关,图上还在」。会话态一律由前端持有,后端只发指令。

坑:zoom_to 接受 center + zoom 但不校验坐标系。前端用 EPSG:3857,模型给的是经纬度,地图会飞到南极附近。契约里必须写死坐标系,或在工具内统一转换成前端 CRS 再返回。

4.5.4 统一错误码与幂等约定

所有工具返回同一个信封,模型只需学一次解析规则:

{"ok": false, "code": "FORBIDDEN", "message": "越权访问", "hint": "该图层要求登录且属于本单位;请改用 edu_facility_public", "retryable": false}
错误码 触发条件 模型应该怎么做 retryable
INVALID_PARAM 类型/枚举/范围不符 按 hint 修正一次 true
NOT_FOUND 图层/要素/行政区不存在 先检索再调用 true
FORBIDDEN 白名单外、越权、未登录 不要重试,告知用户 false
TIMEOUT 查询或分析超时 缩小范围、降 limit true
UPSTREAM_ERROR 数据库/服务异常 退避重试一次 true
EMPTY_RESULT 合法但无数据 直接作答,不要重试 false

幂等与写操作的两条硬约定:只读工具与写工具分两个注册表,写工具默认不出现在系统提示的工具列表里,需要用户在界面上显式开启;所有写操作必须二次确认——模型先调 prepare_xxx 拿到 confirm_token,前端弹确认框,用户点确认后再调 commit_xxx 带上 token。这样即使模型被诱导,也无法在一次调用里完成写入。

# confirm.py —— 两阶段写操作,token 有 TTL 且一次性
import secrets, time

_PENDING: dict[str, dict] = {}

def prepare(action: str, payload: dict, ttl_s: int = 120) -> dict:
    token = secrets.token_urlsafe(16)
    _PENDING[token] = {"action": action, "payload": payload, "exp": time.time() + ttl_s, "used": False}
    return {"ok": True, "data": {"confirm_token": token,
                                 "summary": f"将执行 {action},参数 {payload}",
                                 "expires_in": ttl_s}}

def commit(token: str) -> dict:
    rec = _PENDING.get(token)
    if not rec or rec["used"] or rec["exp"] < time.time():
        return {"ok": False, "code": "INVALID_PARAM", "hint": "确认令牌已失效,请重新发起"}
    rec["used"] = True
    return {"ok": True, "data": {"action": rec["action"], "payload": rec["payload"]}}

坑:把「二次确认」做成前端弹窗但没有服务端 token 校验。攻击面在于模型可以一次调用直接写库;确认框只是 UI,绕过接口就没用了。确认必须在服务端校验 token。

坑:错误码只给用户看,不给模型看。前端展示「操作失败」,模型收到 {"ok": false} 后只能重试。hint 是给模型的,必须写「怎么改」,前端展示时可以折叠。

4.5.5 返回体预算:4 KB 摘要加句柄,超限怎么办

4.4.2 的最后一条阈值是「返回体 ≤ 4 KB 摘要 + 句柄」。这条阈值决定了所有工具的返回形状:模型只看摘要,全量数据靠句柄换。写工具时按固定顺序裁剪——先砍样例条数,再砍字段,最后才动分页。

裁剪顺序 动作 何时用 代价
1 样例从 5 条降到 2 条,保留 total 列表类工具 模型对数据分布感知变弱
2 剥离几何与长文本字段,只留属性摘要 要素类工具 需要看几何时得换句柄取
3 超 4 KB 直接换句柄:返回 result_ref + 游标 统计、导出、全量查询 多一次取数调用
4 句柄内部再分页,按 next_cursor 翻 结果确实需要全量进上下文时 轮数增加,受步数上限约束
# result_budget.py —— 4 KB 硬预算:先裁样例,再换句柄,最后分页
import json, uuid

MAX_BYTES = 4096          # 与 4.4.2 的阈值保持一致,CI 里断言同一个常量
_STORE: dict[str, dict] = {}   # 生产环境换成 Redis,带 TTL

def size_of(obj) -> int:
    return len(json.dumps(obj, ensure_ascii=False).encode("utf-8"))

def make_handle(payload: dict, ttl_s: int = 600) -> str:
    ref = f"ref_{uuid.uuid4().hex[:12]}"      # 随机且不可猜,不要用可枚举自增 id
    _STORE[ref] = {"payload": payload, "ttl_s": ttl_s}
    return ref

def budgeted(rows: list[dict], sample: int = 5) -> dict:
    """返回体超 4 KB 就换句柄;未超就返回摘要 + 总数。"""
    summary = {"items": rows[:sample], "total": len(rows), "truncated": len(rows) > sample}
    if size_of(summary) <= MAX_BYTES:
        return {"ok": True, "data": summary}
    ref = make_handle({"rows": rows})
    slim = {"sample": rows[:2], "total": len(rows), "result_ref": ref,
            "hint": "明细超过 4 KB,需要完整数据请带 result_ref 调 fetch_result,按 next_cursor 翻页"}
    return {"ok": True, "data": slim}

def fetch_result(result_ref: str, cursor: int = 0, page_size: int = 50) -> dict:
    """句柄取数:一次只返回一页,模型自己决定还要不要下一页。"""
    rec = _STORE.get(result_ref)
    if rec is None:
        return {"ok": False, "code": "NOT_FOUND", "hint": "句柄已过期,请重新查询"}
    rows = rec["payload"]["rows"]
    page = rows[cursor: cursor + page_size]
    next_cursor = cursor + page_size if cursor + page_size < len(rows) else None
    return {"ok": True, "data": {"items": page, "next_cursor": next_cursor,
                                    "total": len(rows), "page_size": page_size}}

两个配套约定:句柄必须有 TTL(本轮对话时长 + 余量),过期返回 NOT_FOUND 而不是空数组;句柄必须带权限校验,取数时重新校验调用方对这批数据是否可见,不能因为「有句柄」就放行——句柄是引用,不是授权。

坑:句柄只存缓存不存来源。三天后回放 trace,句柄早过期,审计无从还原。句柄记录里要带 (tool, args_digest, trace_id),过期后仍能按 trace 重跑查询。

坑:把分页游标做成偏移量却允许数据变动。两次翻页之间插入新数据,会重复或漏条。游标要么用稳定排序键(如 (updated_at, id)),要么对查询结果做快照。

坑:摘要里保留完整字段名但裁值。字段越多,模型越容易拿一个空字段编出结论。裁剪时字段和白名单一起砍,宁可少给两个字段。

4.6 从提示词到地图响应的全生命周期

4.6.1 一条完整请求的 trace

用户原话:「查一下这三个街道的学校分布,按每平方公里密度分色,再叠加医院。」这句话里有三类意图(查询、分析+渲染、叠加)、一个隐含实体(三个街道 → 行政区代码)、一个隐含口径(密度 = 学校数 / 街道面积)。下面是这条请求的完整 trace,序列即宿主实际发出的调用顺序:

{
  "trace_id": "tr_20260412_7f3a",
  "user_text": "查一下这三个街道的学校分布,按每平方公里密度分色,再叠加医院",
  "state_in": {"admin_level": "street", "selected_adcodes": ["330110001", "330110002", "330110003"]},
  "steps": [
    {"step": 1, "tool": "resolve_place", "args": {"text": "这三个街道", "use_selection": true},
     "result": {"ok": true, "data": {"adcodes": ["330110001", "330110002", "330110003"], "names": ["A街道", "B街道", "C街道"]}}},
    {"step": 2, "tool": "stat_by_polygon", "args": {"collection": "edu_facility", "polygon_set": "street", "adcodes": ["330110001", "330110002", "330110003"]},
     "result": {"ok": true, "data": {"rows": [{"adcode": "330110001", "count": 12, "area_km2": 1.85}, {"adcode": "330110002", "count": 8, "area_km2": 1.02}, {"adcode": "330110003", "count": 5, "area_km2": 2.10}], "result_ref": "stat:9c1e"}}},
    {"step": 3, "tool": "set_render_rule", "args": {"layer_id": "edu_facility", "method": "graduated", "field": "density_per_km2", "classes": [2, 6, 10], "colors": ["#fee8c8", "#fdbb84", "#e34a33"]},
     "result": {"ok": true, "data": {"rule_id": "rr_31", "applied": true}}},
    {"step": 4, "tool": "toggle_layer", "args": {"layer_id": "hospital_2023", "visible": true},
     "result": {"ok": true, "data": {"layer_id": "hospital_2023", "visible": true}}}
  ],
  "final": {"answer": "三个街道共有学校 25 所,密度 4.4~7.8 所/平方公里,B 街道最密;已按密度分三档着色并叠加医疗机构图层。",
            "map_actions": ["zoom_to:extent(330110001-3)", "render:edu_facility:rr_31", "layer:hospital_2023:on"],
            "legend": [{"label": "2~6 所/平方公里", "color": "#fee8c8"}, {"label": "6~10", "color": "#fdbb84"}, {"label": ">10", "color": "#e34a33"}],
            "evidence": ["stat:9c1e", "rr_31"]}
}

这条 trace 值得注意的有三点。第一,密度不是模型算的,是 stat_by_polygon 返回 count 与 area_km2 后由渲染层按 density_per_km2 字段绑定的;模型只负责决定「用密度而不是数量」。第二,没有一步让模型写 SLD 或 SQL,所有参数都是结构化枚举。第三,final 里的 map_actions 是给前端的指令,legend 与 evidence 让回答可解释。

4.6.2 各阶段失败模式与兜底

阶段 失败模式 表现 兜底
意图识别 把「叠加医院」当成查询 只返回医院列表,没上图 系统提示固定「叠加 = toggle_layer」,评测集覆盖
实体抽取 地名歧义(两个「城关镇」) 查到错区 resolve_place 返回 AMBIGUOUS,让用户选
工具编排 先渲染后统计 渲染报「字段不存在」 工具返回值校验 + 重规划一次
参数绑定 分级阈值非法(降序、重复) 前端着色失败 set_render_rule 服务端校验后返回 INVALID_PARAM
数据为空 该街道无学校数据 空图层,用户以为失败 返回 EMPTY_RESULT + 建议换数据源
渲染冲突 同图层已有专题 新规则覆盖旧规则 规则带 rule_id,可回退

地名歧义是最常见的端到端杀手。resolve_place 必须返回候选而不是硬选:

# resolve_place.py —— 歧义就返回候选,让模型/用户选,不要猜
CANDIDATES = {
    "城关镇": [{"adcode": "330110001", "full": "杭州市余杭区城关镇"},
               {"adcode": "330127001", "full": "杭州市淳安县城关镇"}],
}

def resolve_place(text: str, use_selection: bool = False) -> dict:
    hits = CANDIDATES.get(text, [])
    if len(hits) == 1:
        return {"ok": True, "data": {"adcodes": [hits[0]["adcode"]], "name": hits[0]["full"]}}
    if len(hits) > 1:
        return {"ok": False, "code": "INVALID_PARAM", "retryable": True,
                "hint": "地名有歧义,请让用户从候选中选择,不要自行假设",
                "data": {"options": hits}}
    return {"ok": False, "code": "NOT_FOUND", "hint": "检索无结果,试更完整的行政区名"}

坑:把 data.options 里的候选只放在 hint 文本里。模型能读到,但前端渲染不出可点选项,用户只能重打一遍。候选要放到独立的 data.options 字段,前端直接渲染按钮。

坑:编排阶段不允许重规划。要求「一次规划必须完全正确」的工程实现,遇到「先渲染后统计」这种顺序错误就整条失败。正确做法是每步执行前校验产物依赖(渲染工具要求 stat_ref 已存在),不满足时允许再规划一次。

4.6.3 前端消费:地图指令协议

后端不直接操作地图,返回一个有序的指令数组,前端 reducer 顺序执行。协议只有 6 个动作,够覆盖 90% 的场景:

[
  {"op": "zoom_to", "bbox": [119.85, 30.20, 120.15, 30.42], "padding": 40},
  {"op": "render", "layer_id": "edu_facility", "rule": {"method": "graduated", "field": "density_per_km2", "classes": [2, 6, 10], "colors": ["#fee8c8", "#fdbb84", "#e34a33"]}},
  {"op": "layer", "layer_id": "hospital_2023", "visible": true},
  {"op": "highlight", "layer_id": "edu_facility", "ids": ["f_101", "f_102"]},
  {"op": "legend", "items": [{"label": "2~6 所/平方公里", "color": "#fee8c8"}]},
  {"op": "toast", "level": "info", "text": "已按密度着色,共 25 所学校"}
]
// mapReducer.js —— 前端按序执行指令,失败不回滚已完成动作,但要上报
export function applyMapActions(map, actions) {
  const log = [];
  for (const a of actions) {
    try {
      switch (a.op) {
        case "zoom_to":
          map.fitBounds([[a.bbox[1], a.bbox[0]], [a.bbox[3], a.bbox[2]]], { padding: a.padding ?? 40 });
          break;
        case "render":
          map.setPaintRule(a.layer_id, a.rule);        // 项目内的图层控制器
          break;
        case "layer":
          map.setLayerVisible(a.layer_id, a.visible);
          break;
        case "legend":
          map.setLegend(a.items);
          break;
        case "toast":
          map.toast(a.level, a.text);
          break;
        default:
          throw new Error("unknown op " + a.op);
      }
      log.push({ op: a.op, ok: true });
    } catch (err) {
      log.push({ op: a.op, ok: false, error: String(err) });
    }
  }
  return log;                 // 回传给后端,写入 trace,便于回放时定位
}

坑:指令数组做成「全量替换地图状态」。用户手动调过视口、开过图层,一次智能体回答全给他覆盖掉。指令必须是增量的,且 zoom_to 这类会抢视口的动作要在用户手动操作后标记为「不自动执行」。

坑:前端执行失败只 console.error。回放时看不出图为什么没变。applyMapActions 的返回值必须回传后端写进 trace,这是 4.8 节定位问题的唯一线索。

4.6.4 在 DSH 里回放 trace:把一次会话变成回归用例

4.6.1 的 trace 不只是给人看的日志,它是回归测试的原料。工程做法分三步:导出 JSONL、用 DSH headless 批量回放、把差异落成报告。这里只讲工程形态,指标与评测体系见第 10 章。

# 1) 导出:把生产 trace 转成一行一用例的回放集(脱敏后落盘)
python scripts/export_traces.py --since 2026-10-01 --redact org_id,user_id --out eval/replay_v4.jsonl
# 2) 回放:headless 跑同一批输入,旧 trace 作为期望序列,新结果写 runs/
dsh --profile headless "逐行读取 eval/replay_v4.jsonl,对每条 text 重新跑一遍智能体,记录工具序列、参数、返回码与最终答复;与文件中的 expect_tools / expect_params 比对,输出 runs/replay_v4.diff.md。只读 eval/,允许写 runs/,不要修改工具实现与 schema。" --json > runs/replay_v4.events.jsonl
# 3) 归因:按错误码分组,给出「由 true 变 false」的样本清单
python scripts/summarize_replay.py runs/replay_v4.events.jsonl --group-by code --out runs/replay_v4.summary.md

回放集的字段与 4.8.1 的评测样本同构,只多两个字段:trace_id(来源)与 recorded_at(录制时间)。这样同一套判分代码既能吃人工构造的评测集,也能吃线上导出的回放集。

{"id": "r_20261012_0031", "text": "把这三个街道的学校按密度分色", "trace_id": "tr_20261012_7f3a",
 "expect_tools": ["resolve_place", "stat_by_polygon", "set_render_rule"],
 "expect_params": {"method": "graduated", "field": "density_per_km2"},
 "expect_result": {"layer_id": "edu_facility"}, "recorded_at": "2026-10-12T09:14:22+08:00"}

回放必须固定三个版本,否则结果不可比:模型版本(就绪后写进报告的头部)、工具 schema 版本(用 git commit 或 schema_version 字段)、以及提示词版本。三者任一变化,回放结果都要重新建立基线。

回放场景 触发时机 判读方式
模型或提示词变更 每次升级前后各跑一次 只看「工具序列是否等价」,不看文字表述差异
工具 schema 变更 改 schema 的 PR 内 参数正确率不能降;新增必填字段要配迁移说明
线上故障复现 收到用户投诉后 用同一 trace 重跑,能在本地复现即算定位成功
数据源变更 图层目录、行政区划调整后 关注 NOT_FOUND 与 EMPTY_RESULT 的占比变化

坑:回放直接打生产环境。回放会短时间内产生大量重复查询,既压垮服务也污染数据。回放要指向只读副本或录制的假数据服务。

坑:把回放当成「跑一遍没报错就算通过」。没有期望序列就无法判分,跑通只证明服务活着。回放集必须有 expect_tools,评的是「是否走对链路」。

坑:导出 trace 时不做脱敏。用户原话、单位名、坐标都可能是敏感信息;org_id、user_id 与要素名里的个人信息必须在导出阶段脱敏,别指望下游再处理。

4.7 常用场景的智能化改造

4.7.1 场景一:地图控制

地图控制是最容易见效的场景,工具链短、失败成本低,适合作为第一个上线场景。五组可直接进评测集的模板:

用户原话 意图 期望工具序列 前端动作 验收标准
「放大到余杭区」 定位 resolve_place → zoom_to fitBounds 视口落在区界内,缩放级别 ≥ 11
「把教育设施关掉」 图层开关 search_layers → toggle_layer(visible=false) setLayerVisible 图层不可见,图层树勾选同步
「显示 2023 年的学校」 图层 + 时间过滤 search_layers → set_layer_filter setLayerFilter 只渲染 2023 年要素
「这三个点之间量一下距离」 测距 measure(distance, ids) drawMeasure 返回米/公里,图上画出线
「存成书签,叫我学区视图」 写操作 prepare_bookmark → 用户确认 → commit_bookmark 弹确认框 未确认前不落库

「关掉教育设施」这个例子最能看出工具粒度的差别。如果只有 do_map(action, params) 一个工具,模型要猜 action 的字符串写法;拆成 search_layers + toggle_layer 后,模型的第一步是确定 layer_id,错误被限制在检索这一步,而检索失败有明确 hint。

坑:把所有地图操作做成一个 map_control 工具,参数里 action: string。评测会发现模型在 action 上反复试错。凡是模型需要「背字符串枚举」的地方,都应该拆成工具或多值 enum。

坑:「关掉刚才那个图层」没有 last_action 就无从下手。地图状态快照里必须带最近一次动作,仅靠对话历史无法消解「那个」。

4.7.2 场景二:数据叠加与专题渲染

专题图的关键决策:不要让模型生成 SLD、Mapbox Style 或任何样式文本。样式是结构化数据,模型只负责「选方法、选字段、给分级」,数值与色带由前端或渲染服务执行。

{
  "layer_id": "edu_facility",
  "method": "graduated",
  "field": "density_per_km2",
  "breaks": [2, 6, 10],
  "colors": ["#fee8c8", "#fdbb84", "#e34a33"],
  "normalize": null,
  "classification": "natural_breaks",
  "legend": {"title": "学校密度(所/平方公里)", "position": "bottom-right"}
}
渲染方法 method 取值 必填参数 适用
分级设色 graduated field、breaks(升序)、colors(= breaks+1) 数值字段、分区统计
分类着色 categorical field、mapping(值→颜色) 类型字段、≤ 10 类
热力 heatmap weight_field、radius 点密集、连续分布
聚合 cluster radius、min_count 点位过多(> 5 万)
单色 single color、opacity 叠加辅助层

时序对比走 set_layer_filter + set_render_rule 两步:先用 year 过滤要素,再按当年的字段渲染。不要为每个年份建一个图层,图层数量会指数膨胀。

# theme_tool.py —— 渲染规则的服务端校验,结构非法直接打回给模型
from result import ok, fail

METHODS = {"graduated": 3, "categorical": 2, "heatmap": 1, "cluster": 0, "single": 0}
HEX = set("0123456789abcdefABCDEF")

def set_render_rule(layer_id: str, method: str, field: str | None = None,
                    breaks: list[float] | None = None, colors: list[str] | None = None) -> dict:
    if method not in METHODS:
        return fail("INVALID_PARAM", f"method 只能是 {sorted(METHODS)}")
    if method == "graduated":
        if not breaks or len(breaks) < 1 or breaks != sorted(breaks):
            return fail("INVALID_PARAM", "breaks 必须是升序且至少 1 个")
        if not colors or len(colors) != len(breaks) + 1:
            return fail("INVALID_PARAM", f"graduated 需要 {len(breaks) + 1} 个颜色")
    for c in colors or []:
        if not (len(c) == 7 and c[0] == "#" and set(c[1:]) <= HEX):
            return fail("INVALID_PARAM", f"颜色 {c} 不是 7 位十六进制")
    return ok({"layer_id": layer_id, "method": method, "field": field,
               "breaks": breaks, "colors": colors, "rule_id": "rr_auto"})

坑:让模型直接输出色带。模型会给出毫无区分度的颜色(比如三个近似的蓝),还会偶尔输出非法 hex。颜色应作为枚举白名单提供,模型只选 palette_id。

坑:分级阈值让模型自由算。阈值要靠数据分位数算出来再回填,模型只在「等间距/分位数/自然断点」三种方法里选。否则问「按密度分三档」时,模型会按自己的想象编阈值。

4.7.3 场景三:分析上图全链路

「以每个学校为中心做 500 米缓冲区,统计缓冲区内的公交站数量,按数量分级上图」——这条链是完整的能力层测试题,涉及分析、统计、渲染三层:

用户原话
  │
  ▼ resolve_place / search_layers ──▶ layer_id=edu_facility, bus_stop
  ▼ buffer(layer_id=edu_facility, distance_m=500) ──▶ tmp_buf_8f2a(临时图层,TTL 30min)
  ▼ stat_by_polygon(input=bus_stop, polygon=tmp_buf_8f2a, agg=count) ──▶ rows[{fid, count}]
  ▼ join_attribute(from=edu_facility, to=rows) ──▶ 源图层临时字段 bus_cnt
  ▼ set_render_rule(method=graduated, field=bus_cnt, breaks=[3,6,10]) ──▶ rule_id
  ▼ 结果组装:图层 + 图例 + 结论句 + 表格

这里必须守住两条:临时图层有 TTL,用完即弃,不进图层目录;统计结果以 rows 的形式回传给模型(最多前 10 行),完整结果挂 result_ref 给前端。

坑:缓冲区分析直接写库。临时图层写进正式集合后,图层树里会逐渐堆满 tmp_xxx。临时结果一律走内存或带 tmp_ 前缀且 TTL 索引的集合。

坑:结论句让模型自行发挥。「平均每个学校周边有 4.2 个公交站」必须由工具算好放进返回体,模型只做语言组织。让模型做除法,是数字幻觉的主要来源。

4.7.4 场景四:巡检告警联动

这条链跨章节:第 02 章的无人机巡检产生告警点位(见 低空 AI 巡检实战),第 03 章做空地融合,本章负责「从告警到图、到人、到单」的对话入口。

用户原话 意图 工具链 前端动作 验收标准
「今天有哪些告警」 查询 query_features(collection=alerts, conds=[date,today]) 列表 + 点位高亮 数量与后台一致
「看看 A 点的现场」 定位+取流 zoom_to(layer_id=alerts, ids=[A]) → get_stream(point_id) 地图定位 + 播放器 视口居中,流可播
「这个告警派给三组」 写操作 prepare_dispatch → 确认 → commit_dispatch 弹确认框 未确认不生成工单
「最近一周哪类告警最多」 统计 aggregate_alerts(group_by=type, window=7d) 柱状图 + 结论 排序与明细一致

这一场景的额外约束是时间与权限:告警数据涉密程度高,工具必须带 org_id 作用域(从登录态取,不由模型传),且默认只看最近 7 天。用户说「全部告警」时,工具应返回 FORBIDDEN 并提示可用范围,而不是真的去查全量。

坑:把 org_id 做成工具参数让模型填。模型会填错,更糟的是会被提示注入诱导填别的单位。作用域参数永远从会话上下文注入,不出现在 schema 里。

坑:告警联动里混入写操作却没有审计。派单失败要能回答「谁在什么时候把哪个告警派给了谁」,审计表至少记 trace_id、user_id、tool、args_digest、result_code。

4.8 评测、安全与治理

4.8.1 评测集与五个指标

评测集不是「收集 100 句用户提问」,而是「问题 + 期望工具序列 + 期望结果」。每条样本的字段固定,才能自动判分:

{"id": "e_001", "text": "余杭区有多少所学校", "expect_tools": ["stat_features"],
 "expect_params": {"adcode": "330110", "facility_type": "school"},
 "expect_result": {"count": 186}, "tags": ["query", "single_tool"], "negative": false}
指标 判分方式 门槛 常见失败原因 2026 工具链
意图准确率 首轮意图标签 vs 期望标签 ≥ 92% 组合意图被判成单一意图 路由档 deepseek-v4-flash 固定 temperature=0 + 枚举输出;样本落 JSONL,脚本批量判分
工具选择准确率 期望序列是否为实际序列的子序列 ≥ 85% 多调一次检索、漏掉渲染 DSH headless 回放(4.6.4):导出 trace 为 JSONL,逐条重跑比对序列
参数正确率 expect_params 全字段命中 ≥ 95% 行政区码、时间窗、枚举值 工具 schema 校验进 CI:jsonschema 用同一份 schema 同时约束模型输出与宿主入参
端到端成功率 有最终图层且与期望结果一致 ≥ 80% 中间步骤失败后未恢复 MCP server 单测:tools/list 与 tools/call 走协议层断言,不依赖前端
平均调用轮数 工具调用次数均值 ≤ 5 工具粒度过细 回放输出里统计 len(trace.steps);轮数上涨先在 4.4.2 的阈值上找原因

这一列不是工具清单堆砌,而是「每个指标靠什么自动化」。2026 年做这件事的顺手程度比两年前高很多:DSH 的 headless 模式把「跑一批 → 对比基线 → 归因失败样本」变成一次任务书(4.8.2),MCP 的协议层单测让工具行为可以脱离模型验证,JSON Schema 校验让参数规范既是给模型的提示、也是 CI 里的断言。三者合起来,评测才可能做到「改一行工具描述就跑一遍全量」。

评测脚本骨架(离线跑,不依赖前端):

# eval_agent.py —— 逐条回放评测集,输出 CSV;模型与工具版本固定后结果才可比
import csv, json, sys
from agent import build_agent          # 你的 Qwen-Agent 组装函数
from tools.registry import ToolRegistry

def score_one(case: dict, trace: dict) -> dict:
    used = [s["tool"] for s in trace["steps"]]
    expect = case["expect_tools"]
    it = iter(used)
    seq_ok = all(any(t == u for u in it) for t in expect)   # 子序列判定
    params_ok = all(
        any(s["tool"] == expect[0] and all(str(s["args"].get(k)) == str(v) for k, v in case.get("expect_params", {}).items())
            for s in trace["steps"])
    )
    end_ok = trace.get("final", {}).get("ok", False)
    return {"id": case["id"], "seq_ok": seq_ok, "params_ok": params_ok,
            "end_ok": end_ok, "steps": len(used), "tags": ";".join(case.get("tags", []))}

def main(set_path: str, out_path: str) -> None:
    cases = [json.loads(line) for line in open(set_path, encoding="utf-8") if line.strip()]
    agent, registry = build_agent()              # 注入受控工具
    rows = []
    for case in cases:
        trace = agent.run(case["text"])           # 内部走 4.2 的循环,记录每一步
        rows.append(score_one(case, trace))
    with open(out_path, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
        writer.writeheader(); writer.writerows(rows)
    n = len(rows)
    print(f"seq={sum(r['seq_ok'] for r in rows)/n:.3f} "
          f"params={sum(r['params_ok'] for r in rows)/n:.3f} "
          f"end2end={sum(r['end_ok'] for r in rows)/n:.3f} "
          f"avg_steps={sum(r['steps'] for r in rows)/n:.2f}")

if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2])

4.8.2 用 DSH 跑批量回归

评测的工程化瓶颈不是脚本,而是「每次改工具描述都要重跑一遍全量」。把这件事交给 DSH 的 headless 模式:一个任务书完成「跑评测 → 对比基线 → 定位回归样本 → 按失败类型归类」四步,产物落到 runs/ 下,人工只看摘要。

# 回归一次改动:基线是 v3,新版本是 v4,输出Markdown 对比报告
cd ~/work/onemap-agent
dsh --profile headless "执行以下回归:
1) 运行 scripts/eval_agent.py eval/golden_v3.jsonl runs/v4.csv;
2) 与 runs/v3.csv 逐样本对比 seq_ok / params_ok / end_ok,列出所有由 true 变 false 的样本 id;
3) 对每个回归样本,从 runs/trace/<id>.json 中找出首个失败工具与错误码,按 INVALID_PARAM/NOT_FOUND/其他 归类;
4) 输出 runs/v4.report.md:三行总指标、回归样本表、按错误码分组的计数。
只读:eval/、runs/、scripts/;允许写 runs/。不要修改 schema 与工具实现。" --permission-mode workspace-write

这种任务书的关键是给了固定的比较口径和写权限边界。如果不写「不允许修改 schema 与工具实现」,DSH 为了让指标好看,有可能去改测试而不是改实现——这不是它恶意,而是任务书没定义「什么算修复」。

坑:模型换了版本不跑全量,只抽 10 条看看。抽检覆盖率不足 5%,看不到工具选择偏移。只要模型、提示词、工具描述任一变更,就必须全量回归。

坑:评测集一次成型后不再扩充。上线后把真实失败请求脱敏后补进评测集,每月至少补 20 条,否则评测分数会越来越乐观而线上越来越差。

4.8.3 安全、成本与治理清单

风险 控制手段 验证方法
越权查询 集合/字段白名单、org_id 从会话注入、只读账号 传白名单外集合返回 FORBIDDEN
注入 禁止模型传原生查询;结构化条件 + 服务端拼装 在枚举字段上传 SQL 片段应被判非法
数据外泄 行数上限、几何剥离、结果水印、导出需二次确认 大结果集被截断且带 truncated
提示注入 检索到的文档/属性值不进入系统提示;工具返回体做转义 在要素名称里写「忽略以上指令」应无效
审计 记录 trace_id/user/tool/args_digest/code/耗时 抽查一条请求能还原调用链
成本失控 结果摘要、缓存、小模型做意图分类、步数上限 单请求 token 与工具次数有硬上限
工具被滥用 MCP server 侧最小权限账号(只读副本 + 集合白名单),工具粒度与返回体按 4.4.2 阈值卡死 用 server 自己的账号连库,删掉写权限后仍能跑通全部只读工具
宿主越权 DSH 侧 sandbox(read-only / workspace-write / danger-full-access)× approval 双轴,与 server 权限互不替代 即使 server 返回了不该给的数据,DSH 侧读工作区外路径仍被拒

上下文成本是长期运行的主要开销。三个立刻能降的手段:工具返回体统一裁剪到 5 条样例 + 总数(模型不需要 5000 条);同一轮内相同参数的查询命中缓存;用 1.7B/4B 小模型做意图分类与槽位抽取,只在真正需要编排时才调用大模型。

# budget.py —— 单请求预算,超了就降级,不要"再试一次"
from dataclasses import dataclass

@dataclass
class Budget:
    max_steps: int = 6
    max_tokens: int = 20000
    max_tool_ms: int = 15000
    used_steps: int = 0
    used_tokens: int = 0
    used_ms: int = 0

    def allow_step(self) -> bool:
        return self.used_steps < self.max_steps and self.used_tokens < self.max_tokens

    def spend(self, tokens: int, ms: int) -> None:
        self.used_steps += 1
        self.used_tokens += tokens
        self.used_ms += ms

坑:把「审计日志」写成「记录用户输入」。审计要回答的是「哪个工具有没有越权」,必须记录工具名、参数摘要、返回码与耗时。只记一句话的日志在追责时没有任何用。

坑:缓存不区分用户与权限。按 (user_id, org_id, tool, args) 做 key,否则 A 单位缓存的结果会被 B 单位命中,等于绕过权限。

4.9 本章小结

  • 智能一张图的分层就是安全边界:模型只看得见能力层,数据层与服务层对模型不可见,工具白名单在能力层内部实现,不靠提示词约束。
  • 验收标准不要写成「能聊天」,用意图准确率、工具选择准确率、参数正确率、端到端成功率、平均调用轮数、可回放率六个可测量指标。
  • 宿主侧消息循环必须有步数上限、超时、工具结果裁剪与重试预算;重试次数由宿主计数,不让模型自己决定。
  • 三种范式按层组合最省成本:外层 Plan-and-Execute 拆子任务,子任务内部 ReAct,仅在参数校验失败时做一次 Reflexion。
  • 工具名用「动词_名词」,description 写清「何时用与何时不用」,参数用 enum/pattern/maximum 收紧,一个工具只承载一个用户可理解的动词。
  • MCP 把 GIS 能力从单一客户端解耦成可复用服务,但协议本身不提供权限,集合与字段白名单、只读账号、行数上限必须在 server 内部实现。
  • 所有工具返回统一信封 {ok, data, code, hint, retryable},hint 写给模型看(告诉它怎么改参数),错误码决定重试、降级还是停止。
  • 写操作走两阶段:prepare 拿 confirm_token、前端确认、commit 校验一次性 token;只读与写工具分表注册。
  • 评测集是「问题 + 期望工具序列 + 期望结果」,模型、提示词、工具描述任一变更都要全量回归,回归任务交给 DSH headless 批量跑;trace 可导出 JSONL 做回放,指标与 CI 工具链对应关系见 4.8.1 的「2026 工具链」一列。
  • 模型按角色分工而不是按「越大越好」选:编排用 DeepSeek V4-Pro(1.6T 总参 / 49B 激活 / 1M 上下文),路由用 V4-Flash(284B / 13B 激活 / 1M 上下文),视觉与私有化用 Qwen3-VL / Qwen3-32B;路由与编排的模型档位必须分开配置、分开限流。
  • MCP 以 2026-07-28 规范为准:stateless 核心 + Streamable HTTP 单端点 + 协议版本头,tools/resources/prompts 三类能力分开用;权限在 server 侧用最小权限账号收口,DSH 侧再叠一层 sandbox/approval,两层互不替代。

4.10 动手练习

  1. 工具定义纠错:拿 5 个含自由字符串参数的工具定义,改成 enum + pattern 版本,用 Qwen3 各跑 20 条样本对比工具选择与参数正确率。验收:非法参数被服务端拒绝,且参数正确率高于改造前。
  2. 最小 MCP server:把 4.4.4 的两个工具扩到 4 个,加入字段白名单与 limit clamp,用 MCP inspector 验证 tools/list 与 tools/call。验收:查白名单外集合返回 FORBIDDEN,limit=100000 被压到上限。
  3. trace 回放器:手工构造 4.6.1 的 trace,写一个回放器把 map_actions 还原成地图状态 JSON。验收:同一 trace 重放两次得到完全一致的地图状态,且能定位到失败的指令。
  4. 评测集与选型:构造 50 条评测样本(含至少 15 条负样本),实现 eval_agent.py,分别测 4B 与 32B 档模型。验收:输出五个指标并给出选型结论与成本估算。
  5. 失败注入:给 query_features 注入 3 秒延迟、给 resolve_place 注入歧义数据,验证 TIMEOUT 与 INVALID_PARAM 两条降级路径。验收:请求在预算内结束,前端拿到可点选项。

4.11 延伸阅读