第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 | 对准确率负责的线上入口 |
选型落地的三条判断依据,比看榜单有用:
- 用你的 schema 测,不用通用榜单。把 4.5 节的 12 个工具定义原样喂给候选模型,跑 100 条真实问题,统计「工具名正确 + 必填参数齐全」的比例。小模型在工具数超过 10 时通常掉到 60% 以下。
- 看错误恢复能力。故意给一条错误参数,观察模型能否根据
hint改对。稳定在第一次修正就改对的模型才适合上线。 - 看输出长度分布。同样任务下,模型 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 可单卡部署) | 分辨率、中文标注、输出可结构化 | 编排模型不做像素级判断;视觉模型也不该直接产出最终参数 |
落到部署形态有三种,选哪种由数据能不能出内网决定:
- 云端 API 路线:编排 V4-Pro + 路由 V4-Flash,两个 key 分开配额、分开限流;视觉走自建 Qwen3-VL 的 OpenAI 兼容端点。
- 私有化路线:编排与路由都用 Qwen3-32B(或 235B-A22B MoE),单卡 48~80 GB 或量化后部署;能力下降用更严的 schema、更窄的工具集补。
- 混合路线(行业项目最常见):涉密数据不出内网的部分走本地 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
一个工具定义写得好不好,直接决定模型会不会用错。本章统一三条硬规范:
- name 用「动词_名词」,全小写加下划线,最长 32 字符。不要用
query、data这种没有语义的词,也不要一个名字干三件事。 - description 回答四个问题:这个工具做什么、什么情况下用、什么情况下不要用、返回什么。第二和第三点最容易被省略,而模型的误调几乎都出在这里。
- 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 动手练习
- 工具定义纠错:拿 5 个含自由字符串参数的工具定义,改成
enum+pattern版本,用 Qwen3 各跑 20 条样本对比工具选择与参数正确率。验收:非法参数被服务端拒绝,且参数正确率高于改造前。 - 最小 MCP server:把 4.4.4 的两个工具扩到 4 个,加入字段白名单与
limitclamp,用 MCP inspector 验证tools/list与tools/call。验收:查白名单外集合返回FORBIDDEN,limit=100000被压到上限。 - trace 回放器:手工构造 4.6.1 的 trace,写一个回放器把
map_actions还原成地图状态 JSON。验收:同一 trace 重放两次得到完全一致的地图状态,且能定位到失败的指令。 - 评测集与选型:构造 50 条评测样本(含至少 15 条负样本),实现
eval_agent.py,分别测 4B 与 32B 档模型。验收:输出五个指标并给出选型结论与成本估算。 - 失败注入:给
query_features注入 3 秒延迟、给resolve_place注入歧义数据,验证TIMEOUT与INVALID_PARAM两条降级路径。验收:请求在预算内结束,前端拿到可点选项。
4.11 延伸阅读
- Model Context Protocol 规范(2026-07-28):本章 MCP 口径的唯一依据,stateless 核心与 Streamable HTTP 传输都在这里。
- MCP Python SDK:
FastMCP、stdio 与 Streamable HTTP 的官方实现,4.4.4 与 4.4.6 的骨架据此写成。 - DeepSeek API 模型与定价:V4-Pro / V4-Flash 的上下文长度与单价以此页为准,不要把旧名
deepseek-chat、deepseek-reasoner当现行模型写。 - Qwen3 Technical Report (arXiv:2505.09388):稠密与 MoE 两档的规模与工具调用能力,选型表的数字来源。
- Qwen-Agent 源码仓库:
Assistant/Function/model_server的接口以仓库 README 为准。 - JSON Schema 入门:4.4.1 的
enum/pattern/additionalProperties写法对照。 - MongoDB 查询文档:
MongoDBTool内部拼装 filter 时的语义参考。 - PostGIS ST_Buffer 参考:把 4.4.4 的近似平面缓冲区换成真实空间运算时的入口。