DeepSeek harness插件导航:多工具Agent路由与上下文治理
1. 先把“harness + 插件导航”这件事说清楚
我最早接触 harness 这个词是在做自动化测试的时候,它指的是包在待测系统外面的一层“驾驶舱”,负责给油、踩刹车、记录仪表盘。搬到模型工程里,DeepSeek harness 插件导航本质上就是同一件事:给模型套一层可控的外壳,外壳里挂着一堆插件,再配一份“导航图”告诉模型——你现在该走哪条路、哪个插件能干这活、参数怎么填、失败了找谁兜底。
很多人一上来就盯着模型本身,觉得换个更强的模型就万事大吉,实测下来完全不是这样。模型再强,如果外壳没做好,它会在二十个工具里随机挑一个,会把参数填成它以为的样子,会在拿到报错之后原地重试五次,最后把上下文撑爆。这些问题的根源不在模型,在 harness 的编排逻辑和插件导航的路由质量上。
这套东西解决的问题很具体:工具数量上去之后,怎么让模型选得准、填得对、跑得稳、挂了能查。插件从 3 个涨到 30 个,从“问答”扩展到“查数据、改状态、发通知”,如果没有导航层,准确率是断崖式下跌的,我见过从 92% 掉到 61% 的真实曲线。
适合谁来读:已经在做模型应用、手上有五六个以上工具要接的开发者;正在被“工具选错”“参数漂移”“上下文超限”折磨的同学;以及想把内部一堆零散脚本收编成统一插件体系的技术负责人。刚入门的朋友也能看,我会把每一步的“为什么”讲透,代码都是可以直接跑的最小骨架。
2. 整体架构:外壳到底该拆成几层
2.1 四层职责边界,别混在一起写
我最开始写 harness 的时候,一个agent.py两千行,什么都往里塞,改一个参数要重新读半小时。后来拆成四层,才算活过来。
第一层是会话层,管消息历史、上下文预算、截断策略。它不做任何决策,只负责“这一轮该给模型看多少东西”。第二层是编排层,也就是真正的主循环:拿用户意图、问导航层要候选插件、组 prompt、调模型、解析工具调用、执行、把结果塞回去、判断要不要继续。第三层是工具层,每个插件是一个独立单元,只关心自己的输入输出契约,不关心被谁调用。第四层是观测层,全链路埋点、token 计量、失败归因、会话回放。
这么拆的核心原因是变更频率不一样。会话层的截断策略一周可能改三次,编排层的循环逻辑一个月动一次,插件本身可能半年不碰,观测层则是持续加字段。混在一起写,改截断策略要跑全量回归,成本高到你会放弃优化。
还有一层隐性的好处:可测试性。编排层可以拿假的模型响应跑单测,工具层可以脱离模型单独跑契约测试,导航层可以用历史会话做离线评测。分层之后,每一层都有独立的验证手段,这才谈得上“稳”。
2.2 插件清单协议:manifest 写不好,后面全是坑
插件导航的地基是 manifest,也就是每个插件对外声明的“身份证”。我踩过的最大坑是早期只用name+description+parameters三件套,结果模型光看描述根本分不清“查订单”和“查物流”有什么区别,因为它们描述都写得很像。
现在的 manifest 我固定包含这些字段,缺一个都会在注册阶段直接报错:
| 字段 | 作用 | 常见错误 |
|---|---|---|
name | 全局唯一标识,建议域.动作格式 | 用中文或带空格,路由时匹配不上 |
capabilities | 能力标签数组,导航检索的主要依据 | 标签写太细,一个插件挂 20 个标签等于没标签 |
when_to_use | 正向场景,自然语言写 | 写成功能复述,没有场景区分度 |
when_not_to_use | 反向场景,明确边界 | 直接省略,导致模型乱选 |
risk | 风险等级 low/medium/high | 只标 low,高危操作也走自动执行 |
idempotent | 是否幂等,决定能否安全重试 | 写操作标成幂等,重试导致重复下单 |
timeout_ms | 单次执行超时 | 统一 30000,导致慢插件拖垮整轮 |
input_schema | JSON Schema,参数校验用 | 用type: string糊弄,模型随便传 |
when_not_to_use这个字段是区分度的关键。举个例子,“汇率换算”插件的反向场景可以写“当用户询问的是历史某天的汇率且需要官方牌价时,不要用本插件,改用档案查询”。这一句话能让误选率降下来一大截,因为模型在做选择时拿到的是对比信息,而不是孤立的正面描述。
capabilities标签我建议控制在 3 到 6 个,粒度对齐“用户会怎么描述这个需求”,而不是“代码内部怎么实现”。用户说“帮我看看这个月花了多少”,标签就该是消费统计、账单、月度,而不是sql_query、dwd_layer。
2.3 目录结构与注册中心的位置
目录我习惯这样摆,一眼能看出边界:
harness/ session/ # 会话层:历史、预算、截断 orchestrator/ # 编排层:主循环、解析、重试 router/ # 导航层:召回、打分、裁剪 plugins/ builtin/ weather_query/ manifest.json handler.py internal/ order_lookup/ manifest.json handler.py observe/ # 观测层:trace、指标、回放 registry.py # 注册中心 main.py注册中心做三件事:扫描发现、校验入库、构建索引。扫描支持目录级和入口声明两种模式,目录级省事,入口声明适合插件分散在多个仓库的场景(用 setuptools entry_points)。校验必须是硬失败——schema 不合法、name 重复、标签为空,一律拒绝加载,绝不允许“先跑起来再说”,因为带着脏数据跑出来的路由效果是没有参考价值的。
3. 插件导航的核心:召回、打分、裁剪
3.1 三层路由,规则兜底最不能省
导航层我只做三件事:把可能相关的插件召回出来、给它们打分排序、按预算裁掉一部分。听起来简单,做起来最容易翻车的是“只做语义检索”。
纯语义检索的问题在于:用户说“帮我把这个月的账理一下”,向量召回可能把“账单导出”“消费统计”“预算设置”全捞上来,分数还都很接近,模型只能瞎猜。而规则层可以一句话解决——“提到‘这个月’+‘账’→ 优先消费统计”。
我的三层结构是这样的:
- 规则层:关键词、正则、用户角色、上轮调用的插件。命中即加分,优先级最高。
- 标签层:把用户输入抽成标签(可以先用小模型或词典),与
capabilities求交集,交集中的每个标签记一次分。 - 语义层:把
when_to_use + capabilities拼成一段文本做向量化,和用户输入算余弦相似度。
最终分数用一个可调的加权:
def final_score(rule_hit: float, tag_overlap: float, semantic: float) -> float: # 权重是经验值,规则命中就该压过语义 return 0.50 * rule_hit + 0.28 * tag_overlap + 0.22 * semantic注意:这三个权重不要照搬。工具数量少(10 个以内)时语义权重可以提到 0.4;工具多且长尾明显时,规则权重必须压上去,否则长尾插件永远召不回来。
阈值我一般设在 0.55 到 0.62 之间。低于阈值就不调工具,直接让模型用自然语言回答,或者反问用户。这一步非常关键——允许“不调用”比“强行调用”重要得多。早期我没设阈值,模型拿到的候选里总有一个分数最高的,它就一定会调,导致大量莫名其妙的工具调用。
3.2 标签体系怎么设计才不至于失控
标签体系最怕两件事:一是无限膨胀,二是层级混乱。我的做法是三层标签 + 受控词表:领域(订单、账户、内容)、动作(查、改、删、导出)、限定(本月、异常、批量)。插件注册时只能从词表里选,选不到就提 PR 加词,不允许自由发挥。
这么做的好处是标签交集有意义。如果允许自由写,“消费统计”可能被标成账单、花费、支出、开销、money,而用户输入抽出来的标签是花钱,交集为空,标签层直接失效。受控词表下,同义词归一到支出,交集就有了。
用户输入的标签抽取,我用的是“词典 + 规则 + 小模型兜底”的组合。词典覆盖高频词,规则处理“这个月”“上季度”这类时间表达,剩下的交给一个小的分类模型。不需要很准,召回率比准确率重要,因为后面还有打分环节兜着。
3.3 上下文预算:算清楚再裁,别拍脑袋
工具描述是最容易被忽视的上下文消耗大户。一个插件完整描述(含 schema)大概 180 到 320 个 token,30 个插件就是 6000 到 9000 token,还没开始对话就用掉了十分之一窗口。所以裁剪不是优化项,是必需品。
我的预算分配大致是这样,以 64K 窗口为例:
| 用途 | 占比 | 约合 token | 说明 |
|---|---|---|---|
| 系统提示 | 固定 | 600-900 | 包含规则、格式要求 |
| 工具描述 | 18%-22% | 11000-14000 | 按分数排序,动态裁剪 |
| 历史对话 | 35%-40% | 22000-25000 | 超限从最早的整轮丢弃 |
| 工具返回结果 | 20% | 12000-13000 | 大结果先摘要再入上下文 |
| 输出预留 | 12%-15% | 8000-9500 | 必须留够,否则截断 |
裁剪策略是分级的:分数前 8 的插件给完整描述(含完整 schema 和正反场景);8 到 15 名给精简描述(name + 一句话 + 必填参数名,不带类型说明);15 名之后只给 name 列表,告诉模型“如果都不合适,可以从这些名字里再问”。这个降级梯度实测比“一刀切只给前 10 个”效果好很多,因为长尾需求还能被捞回来。
工具返回结果的裁剪同样重要。一次查询返回 500 行 JSON,直接塞进去能吃掉一半窗口。我的规则是:结构化结果超过 20 条就只保留前 5 条 + 总数 + 字段说明,同时把完整结果落到会话存储里,告诉模型“完整数据已缓存,需要细节可以说‘展开第 N 条’”。这样既不丢信息,又不炸窗口。
4. 实操:搭一个能跑的最小骨架
4.1 注册中心实现
先写注册中心,这是所有东西的入口。核心是扫描、校验、建索引三合一。
# registry.py import json from pathlib import Path from dataclasses import dataclass, field from typing import Callable @dataclass class PluginMeta: name: str version: str capabilities: list[str] when_to_use: str when_not_to_use: str risk: str idempotent: bool timeout_ms: int input_schema: dict handler: Callable = None search_text: str = field(init=False) def __post_init__(self): self.search_text = f"{self.when_to_use} {' '.join(self.capabilities)}" REQUIRED = ["name", "version", "capabilities", "when_to_use", "when_not_to_use", "risk", "idempotent", "timeout_ms", "input_schema"] class Registry: def __init__(self): self._plugins: dict[str, PluginMeta] = {} def load_dir(self, root: str): for manifest in Path(root).rglob("manifest.json"): self._load_one(manifest) def _load_one(self, manifest_path: Path): raw = json.loads(manifest_path.read_text(encoding="utf-8")) missing = [k for k in REQUIRED if k not in raw] if missing: raise ValueError(f"{manifest_path} 缺少字段: {missing}") if not raw["capabilities"]: raise ValueError(f"{manifest_path} 的 capabilities 不能为空") if raw["name"] in self._plugins: raise ValueError(f"插件名重复: {raw['name']}") if raw["risk"] not in ("low", "medium", "high"): raise ValueError(f"{raw['name']} 的 risk 取值非法") handler_file = manifest_path.parent / "handler.py" handler = self._import_handler(handler_file) self._plugins[raw["name"]] = PluginMeta(**raw, handler=handler) def _import_handler(self, path: Path) -> Callable: import importlib.util spec = importlib.util.spec_from_file_location(path.stem + "_h", path) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod.run def all(self) -> list[PluginMeta]: return list(self._plugins.values())这里有两个设计取舍值得说。一是硬失败:字段缺失直接抛异常,不做默认值填充。默认值看着友好,实际会让“when_not_to_use全空”这种问题潜伏很久,直到你发现误选率居高不下才回头查。二是 handler 用文件路径动态导入,而不是要求插件注册到某个包内。这样插件可以来自不同仓库,只要目录结构对就能加载,适合内部多个团队各写各的场景。
4.2 路由打分实现
路由层的代码不长,但每个细节都影响效果。
# router/score.py import re from difflib import SequenceMatcher TIME_PAT = re.compile(r"(这个月|上个月|本季度|今天|最近\d+天)") def rule_score(query: str, plugin, last_called: str) -> float: s = 0.0 if TIME_PAT.search(query) and "时间" in plugin.capabilities: s += 0.4 # 上轮调过同一个插件,轻微加权,避免频繁跳转 if last_called == plugin.name: s += 0.15 # 负向场景命中,直接扣到底 for kw in plugin.when_not_to_use.split("、"): if kw and kw in query: s -= 0.6 return max(0.0, min(1.0, s)) def tag_score(query: str, plugin, extractor) -> float: q_tags = set(extractor(query)) p_tags = set(plugin.capabilities) if not p_tags: return 0.0 return len(q_tags & p_tags) / len(p_tags) def semantic_score(query: str, plugin, embedder) -> float: qv = embedder.encode(query) pv = embedder.encode(plugin.search_text) return float(qv @ pv) # 已归一化,点积即余弦 def rank(query, plugins, embedder, extractor, last_called=None, top_k=15): scored = [] for p in plugins: s = (0.50 * rule_score(query, p, last_called) + 0.28 * tag_score(query, p, extractor) + 0.22 * semantic_score(query, p, embedder)) scored.append((s, p)) scored.sort(key=lambda x: x[0], reverse=True) return scored[:top_k]阈值判断我放在编排层做,因为编排层才知道“这一轮是不是已经在工具循环里”。如果已经在第 2 轮工具调用之后,阈值可以适当降低,因为上下文里已经有明确的失败反馈,模型需要的是更多选项而不是更少。
4.3 编排主循环与熔断
主循环最容易写错的地方是循环终止条件。我见过不少实现是“模型不再返回工具调用就结束”,这个条件在模型陷入反复调用的死循环时永远不成立。
# orchestrator/loop.py import hashlib, json MAX_TURNS = 6 DUP_LIMIT = 3 def run_turn(session, user_input, registry, router, model): candidates = router.rank(user_input, registry.all(), ...) trace = [] call_history = {} for turn in range(MAX_TURNS): prompt = session.build(candidates, user_input) resp = model.chat(prompt) if not resp.tool_calls: return resp.text, trace for call in resp.tool_calls: key = hashlib.md5( (call.name + json.dumps(call.args, sort_keys=True)).encode() ).hexdigest() call_history[key] = call_history.get(key, 0) + 1 if call_history[key] > DUP_LIMIT: trace.append({"type": "circuit_break", "call": call.name}) return "这一步反复尝试没有进展,我先停下来,换个思路试试。", trace plugin = registry.get(call.name) if plugin.risk == "high" and not session.confirmed(call): return "这个操作会影响实际数据,需要你先确认一下。", trace result = execute_with_timeout(plugin, call.args) session.append_tool_result(call.id, truncate(result)) trace.append({"call": call.name, "ok": result.ok, "ms": result.ms}) return "本轮处理步数达到上限,建议拆成更小的请求。", traceDUP_LIMIT = 3这个值不是随便定的。设成 1 会误杀“参数改一下重试”的正常行为,设成 5 又太晚,中间已经烧掉大量 token。3 次的经验是:第一次失败可能是参数问题,第二次是理解偏差,第三次还没成基本就是插件本身或前提条件不满足,该停。
高危操作的人工确认不能省。risk == "high"的插件(写库、发通知、改配置)一律要过确认门,而且确认信息要包含具体参数,不能只说“要执行某操作吗”,否则用户点了同意也不知道同意了什么。
4.4 观测:没有回放就没有优化
观测层我最小配置只埋四个东西:trace_id、每轮的候选插件及分数、每次工具调用的入参和耗时、总 token 消耗。这四个字段足够你回答 80% 的“为什么这次没调对”。
离线回放的价值在于,你可以拿历史会话重跑新版本的路由,对比候选列表变化。我每次调权重或改标签词表,都会跑一遍 300 条的历史样本,看准确率和召回率的变化。没有这个环节,调参就是玄学——你今天觉得好了,明天换个说法又崩了。
回放脚本很简单,把历史会话里的 user_input 抽出来,分别用新旧两版路由跑一遍,输出差异表。差异里如果出现“新版把正确的插件排到了第 3 名之外”,那就是回归了,必须回滚。
5. 常见问题与排查技巧实录
5.1 工具选错:八成是描述没区分度
工具选错是最常见的问题,表现是模型调了一个功能相关但业务不对的插件。排查顺序我固定是三步:先看候选列表里正确插件排第几,再看它的分数构成,最后看描述文本。
如果正确插件压根没进候选(前 15 名之外),问题在召回,检查标签和search_text。如果进了候选但排第 5 之后,问题在打分权重,规则层可能没覆盖到。如果排第一但模型还是选了别家,问题在描述——模型看到的信息不足以区分。
第三种情况最隐蔽。我遇到过一个案例:“查询订单状态”和“查询退款进度”两个插件,语义相似度 0.87,标签高度重合,模型在用户问“我那笔钱到哪了”时随机选。解决办法是在两个插件的when_not_to_use里互相点名:“如果用户问的是退款到账,不要用本插件”。一句话,误选率从 30% 降到 6%。
心得:
when_not_to_use里点出兄弟插件的名字,比抽象描述有效得多。模型做的是对比选择,给它对比锚点最管用。
5.2 参数漂移:schema 要写死,不要留白
参数漂移指的是模型填的参数类型对但语义错,比如日期填成“上个月”而不是2024-05,或者枚举值填了不在列表里的项。根源通常是 schema 只写了type: string,没写enum、没写format、没写例子。
我的做法是三件套齐全:enum限定取值、description写清格式、examples给两个真实样例。JSON Schema 里的examples字段很多模型都能读到,效果比纯文字描述好。日期一律用format: "date"并限定YYYY-MM-DD,绝不允许自然语言。
再加一道运行时校验:参数进来先按 schema 校验,不合法直接返回结构化错误给模型,错误信息里必须包含“期望什么、收到什么、正确示例”。我试过只返回“参数错误”,模型会原地重试同样的参数三次;返回具体期望值之后,第二次基本就改对了。
5.3 上下文爆掉:三种典型形态
上下文超限的表现不一定是报错,更多是“回答质量突然变差”或者“模型开始忘记前面的约束”。我总结出三种典型形态:
第一种是工具返回过大,一次性把 500 行结果塞进去。对策是结果超 20 条就摘要,完整数据落存储。
第二种是历史无界增长,多轮对话后前面几十轮全在上下文里。对策是按轮丢弃,但保留“用户的关键约束”(比如用户说过的偏好、禁止项),这些抽出来单独存成一个短小的constraints块,不随历史丢弃。
第三种最阴险,是系统提示和工具描述打架。系统提示里写了“回答要简洁”,工具描述里写了“请详细列出所有字段”,模型在两边拉扯,输出变得又长又乱。对策是定期审查系统提示和插件描述的一致性,尤其是格式要求这类硬约束,只能在一处出现。
5.4 排查速查表
| 现象 | 最可能原因 | 首选动作 |
|---|---|---|
| 调了功能相近的错插件 | 描述缺区分度 | 在when_not_to_use点名兄弟插件 |
| 该调工具却没调 | 阈值过高或召回缺失 | 降阈值到 0.5,检查标签交集 |
| 同一参数反复重试 | 校验错误信息太模糊 | 错误里带上期望值和示例 |
| 第 4 轮之后开始胡说 | 上下文被工具结果挤爆 | 检查结果摘要策略 |
| 高危操作被自动执行 | risk标错或确认门缺失 | 强制 high 风险走确认 |
| 响应越来越慢 | 候选插件过多导致 prompt 膨胀 | 裁剪到 15 个,分级给描述 |
| 换个说法就失效 | 过度依赖语义检索 | 补规则层关键词 |
这张表我贴在工位上,出问题先扫一遍,能省掉一半的排查时间。绝大多数所谓“模型不稳定”,拆开看都是工程问题。
6. 我在实际项目里踩出来的几条经验
插件数量到 20 个以上之后,导航质量比模型能力更决定体验。同一个模型,路由做得好和做得差,任务完成率能差 30 个百分点。所以别急着换模型,先把候选列表和描述质量捋一遍,收益往往更大。
标签词表一定要有人管。放任自由标注,三个月后你会收获一个 200 个标签、彼此含义重叠的烂摊子。我的做法是词表进版本库,新增标签走评审,每季度做一次合并清理。这件事看着琐碎,但它决定了标签层还能不能用。
还有一点:给插件写“一句话人话描述”,就是假设你要跟一个不懂技术的同事解释这个插件干什么,你会怎么说。这句话往往比任何结构化字段都更能提升模型的判断准确率,因为它是从需求侧描述的,而不是从实现侧。
最后分享一个小技巧。当你不确定某个插件该不该被召回时,把它临时标成risk: high跑一轮,看模型在需要确认的时候会不会主动选它。如果它从来不选,说明这个插件的描述有问题,或者它根本就不该存在——很多时候我们加插件是因为“可能有用”,而不是因为“确实有人需要”。