习慈羊xiciyang
联系
订阅
正在读取订阅状态…

让 Agent 自己长技能:一个"检索 → 计划 → 执行 → 沉淀"闭环,和它暴露出的三处安全缺陷

假设你打开知识库搜一个问题,拿到 5 条结果,把第 1 条喂给模型——它答得还行。但你不会注意到:这 5 条其实没有按相关度排序,真正最相关的那条排在第 4 位。

这就是”看起来对”的缺陷:不报错,只是让你持续拿到次优结果。而在我做的这个”agent 自己长技能”的闭环里,这类缺陷的代价被放大了一档。

让 agent 自己长技能,真正的风险在于它把错的做法记成经验:技能库同时是它自己的判据来源,错一次会被记住很多次。

我把这个闭环做通了:检索、计划、白名单执行、成功后把做法蒸馏成技能写回同一个知识库。下面先说这个闭环怎么跑通,再说它暴露的三处缺陷,最后说为什么这类缺陷值得单独记录。通读自己写的代码之后,我找到三处会让它从”效率工具”变成”风险源”的设计缺陷——它们的形状是同一种:检查存在,但检查的层级与风险发生的层级不一致。

1. 闭环:五个动作,和它为什么真的闭上了

Agent 自进化技能闭环:检索 → 计划 → 执行 → 沉淀 → 写回知识库

上一节给了主线,这一节先看闭环本身的形状。

一个任务进来,走的路径是:

  1. 检索知识:同时查内部知识库和外部互联网;
  2. 总结分析:让模型从检索结果里提炼核心流程;
  3. 生成可执行步骤:输出结构化的命令列表与参数;
  4. 白名单执行:逐条执行,任一条失败就停下;
  5. 成功后沉淀:把这次成功执行蒸馏成”技能定义”(名称、参数、执行器、步骤),写回知识库。

第 5 步是这个设计的关键,也是它与”查完让模型输出一段建议”的本质区别:它产出的是下一条可以被检索、可以直接执行的条目,而不是一段回答。

很多”自进化”的架构图,闭环的最后一环是虚线——写回的东西和读的东西不是同一个库,于是”下次能用到”只是一个愿望。

这里不是。写回的目标就是检索的来源:技能以页面形式写回知识库,下一次同类需求检索时,它会作为命中项出现。读和写是同一个存储,闭环才在物理上合上。

这个细节听起来平淡,但它决定了一件事:这套东西会随使用而改变自己的能力边界。换句话说,每成功完成一次任务,这套环境的”能力清单”就被改写一次。第一周它只能答问,第二周它可能已经能执行十几类任务。而正是这一点,让后面的安全问题变得重要——一个会自我修改的系统,比一个只读系统需要更严的边界。

2. 混合检索,和一个”看起来完全正常”的 bug

说完了闭环的形状,接下来看它靠什么检索。

内部检索用的是向量 + 全文的混合。两类查询的性质不同,这不是赶时髦:

  • 语义查询:“怎么让推理服务更稳” → 向量召回擅长(字面不匹配但意思接近);
  • 精确查询:某个参数名、某个报错关键字 → 全文检索擅长(向量反而会把精确串稀释掉)。

混合的做法是各自取一批,再用 RRF 融合——这个方法由 Cormack 等人在 2009 年提出 [1]。它的公式短到只有一行:

scores = {}
for rank, res in enumerate(vec_results):
    scores[res["id"]] = scores.get(res["id"], 0) + 1 / (k + rank + 1)
for rank, res in enumerate(es_results):
    scores[res["id"]] = scores.get(res["id"], 0) + 1 / (k + rank + 1)

sorted_ids = sorted(scores.keys(), key=lambda x: scores[x], reverse=True)[:top_k]

k 取 60。RRF 的价值在于它只依赖名次、不依赖分数——这就绕开了一个很麻烦的问题:向量相似度和 BM25 分数不在同一个量纲上,直接加权是错的,而 RRF 不需要你为它们定权重。

融合算完了,然后我在这里漏了一行

融合出顺序之后,要按这个顺序把正文取回来。我最初是这么写的:

cur.execute("SELECT id, title, content FROM pages WHERE id = ANY(%s::int[])", (sorted_ids,))
rows = cur.fetchall()

results = []
for rid, title, content in rows:
    results.append(SearchResult(id=rid, title=title, content=content[:500], similarity=scores[rid]))
return results

问题出在第一行:没有 ORDER BY。

= ANY(...) 返回的行序是数据库的实现细节——通常按物理存储或索引顺序出,与传入数组的顺序无关。所以结果是这样的:

  • similarity 字段里的分数是对的(来自 scores 字典);
  • 结果的顺序是错的(来自数据库的返回顺序)。

这个 bug 有四个特征,让它特别容易活下来:

  1. 不报错:SQL 合法,结果条数正确,分数正确;
  2. 只在多条命中时显形:只有一条命中时,顺序无所谓;
  3. 人最容易忽略的恰恰是顺序:拿到 5 条结果,你会核对内容和分数,很少去核对”它们是不是按分数排的”;
  4. 它不影响”能不能答对”,只影响”答得好不好”:最相关的那条可能排在第 4 位——这意味着如果下游只取第 1 条喂给模型,效果就悄悄变差了。

修法是把排序交给数据库——用 PostgreSQL 的 array_position 按传入数组的顺序排 [2]:

cur.execute(
    "SELECT id, title, content FROM pages WHERE id = ANY(%s::int[]) ORDER BY array_position(%s::int[], id)",
    (sorted_ids, sorted_ids),
)

(或者简单一点:取回后在 Python 里按 sorted_ids 重排。关键是顺序必须由融合结果决定,而不是由数据库的物理布局决定。)

我把这件事写出来,是因为它属于一类我认为最值得记录的缺陷:它的输出看上去对,却一直不报错。

3. 贯穿线:这个闭环真正改变的是什么

上一节留在那里的那个 bug,是理解这个闭环的关键。

它改变了”错误”的成本结构。

在一个只读的知识库里,一次错误检索的代价是”这一次答得不好”。而在一个会自我修改的系统里,一次”看起来对”的错误会被写进技能库,成为下一次的判据——错误从”一次性事件”变成了”长期资产”。

上一节那个 bug 就是这件事的微观版本:结果集是对的、分数是对的、只有顺序是错的。它不报错,只会让你在一段时间里持续拿到次优结果,而且你没有任何理由去怀疑它。这类缺陷的代价在于它长期存在却不可见,单次错误反而不贵。

所以我从这个项目里得到的最重要的判断,与”如何自动化”无关:

当一个系统开始修改自己的经验来源时,你判断它好不好的依据,就不再来自系统之外。

这句话反过来决定了两件事,也是后半部分的全部:

  • “看起来对”的缺陷在这里代价最高,因为它会被固化;
  • 最危险的默认值,是那些让系统无需确认就写回自己的默认值。

4. 安全审查:三处缺陷

前面都是”怎么做成”。但从闭环合上的那一刻起,这个系统就有了一件事需要回答:

它能自己写技能,那么在什么条件下,它会写下一个危险的动作?

通读代码后,我找到三处。它们都来自设计取向上的放松,与打字错误无关——而它们与上一节那个 bug 是同一形状:检查存在的,但它检查的层级和风险发生的层级不一致。

三处缺陷的共同形状:检查的层级与风险发生的层级不一致

缺陷一:白名单把解释器放进了允许列表

ALLOWED_COMMANDS = ["docker", "bash", "python", "pg_dump", "rsync", "echo"]
# ...
args = shlex.split(cmd)
head, *rest = args                                  # 只取第一个 token
if not args or head not in ALLOWED_COMMANDS:
    return {"success": False, "error": f"Command '{head}' not allowed"}

(上面用的是等价写法 head, *rest = args,原代码用下标取首元素;语义相同。)

这段代码看起来是白名单,但它只校验第一个 token。而 bash 和 python 都在列表里,这意味着:

bash -c "任意命令"
python -c "import os; os.system('任意命令')"

都会通过检查。

同样地,参数注入也不给你一个”看得见的越权动作”:它改动的是命令的行为,而命令本身看起来完全正常。

把一个解释器放进白名单,等于白名单不存在。问题在于边界被绕过,而不是边界太宽:检查仍在执行,但它检查的对象(第一个 token)与真实能力(bash 后面那串东西)之间没有任何约束关系。

缺陷二:自动沉淀默认开启,形成一条自我放大的链

AUTO_PERSIST_SKILLS = os.getenv("AUTO_PERSIST_SKILLS", "true").lower() == "true"

默认 true 的含义是:任务执行成功后,模型把这次执行自动蒸馏成一条技能,自动写回知识库。

单看每一步都合理,连起来是一条自我放大的链:

一次成功执行 → 自动生成技能 → 写回知识库
 → 下次同类需求检索到它 → 直接执行这条"自动生成的技能"
 → 又形成一条新技能 …

问题在于中间没有任何人工确认。而”自动生成的技能”质量如何,取决于模型把那次执行总结得准不准。如果它把一次恰好成功、但原因不相关的执行记成了技能,这条技能就会被后续任务当作经验复用。

更麻烦的是:技能写回的是知识库,而知识库是整套系统的可信来源。也就是说,这条链的污染目标,恰恰是它自己的判据来源——正是上一节那句话的具体后果。

缺陷三:参数替换发生在分词之前

def replacer(match):
    return str(params.get(match.group(1), ""))

cmd = re.sub(r'\{\{(\w+)\}\}', replacer, cmd_template)   # ① 先替换
args = shlex.split(cmd)                                   # ② 再分词

注意顺序:先把参数填进模板,再对整条字符串分词。

这意味着参数里的空格与引号会被当成命令语法来解析,而不只是一个值。假设模板是:

pg_dump -f {{outfile}} mydb

当 outfile 是 dump.sql 时一切正常;但当它是 dump.sql --verbose 或更复杂的输入时,它就从”一个参数值”变成了”额外的命令行参数”。

参数注入:替换先于分词,参数从值变成语法

这是参数注入,不是 shell 注入:代码没有用 shell=True(这点是对的),但它能改变命令的行为,而不需要执行任意命令的能力。对于一个会自动执行、又会把结果沉淀成技能的系统,这个能力已经足够造成麻烦。

三处缺陷的共同点

回头看,这三处的形状是一样的:检查存在的,但它检查的层级和风险发生的层级不一致。

  • 白名单检查了 token 的位置,风险发生在解释器内部;
  • 持久化检查了”执行是否成功”,风险发生在”总结是否可信”;
  • 参数替换检查了”占位符替换”,风险发生在”替换结果被当作语法”。

这也解释了为什么这类问题很难在写代码时被发现:每一行都在做它声称要做的事。

5. 我会怎么加固

三处缺陷是同一个形状,所以加固也按这个形状来——从最危险的默认值开始。

按”先关掉最危险的默认值,再谈精细控制”的顺序:

  1. 白名单只放具体命令,不放解释器。需要复杂逻辑时,写成脚本放进固定目录,白名单里只允许那个脚本路径——把”能力”固化成一个个可审计的入口,而不是留一个通用的执行器。
  2. 自动沉淀默认关闭。改成”生成候选技能 → 人工确认后入库”。这会牺牲一部分自动化,但对一条会改写系统可信来源的链,人工确认是值得的。
  3. 技能条目必须带来源与版本。哪次执行总结出来的、由哪个模型总结、原始命令与输出是什么——否则无法回溯”这条经验怎么来的”,也无法在发现它错时批量撤销。
  4. 参数先分词、再替换。把模板先解析成 token 列表,占位符作为独立的一个 token 被值替换,而不是先拼字符串再分词。这样参数值永远是值,不可能变成语法。
  5. 执行环境要有边界。默认断网、默认只读挂载所需数据卷、限制执行时长——让”执行错了”的后果被环境限制住,而不是只靠命令白名单。
  6. 外部内容的信任级别要低于内部内容。检索结果里混着互联网抓来的文本,而互联网文本是不可信输入;把它和内部知识不加区分地喂给”生成可执行计划”的模型,等于把提示注入的入口直接接到执行器前面。外部内容应只作为参考上下文,不参与生成可执行步骤。

第 6 条是我做完这个闭环之后才意识到的,也是我认为最该先改的一条。所以说,前五条防的是”我自己写错了”,第六条防的是”别人让我写错”。

6. 局限性

因此,在把上面这些当成结论用之前,先把边界说清楚。

  • 第五节的代码结论是”读代码”得出的,没有实际构造攻击验证。三个缺陷的成立条件写在代码里(白名单只校验首 token、参数替换先于分词、自动沉淀默认开),但”能造成多大后果”需要单独评估。
  • 第三节与第六节的通道结论是实测可复现的(REST 端点全 404、唯一可用入口是 /graphql、用 __type 自省取代查文档、tags/locale 等必填字段与转义要求),这部分不是推断。
  • 它是个人实验环境里的一个原型,不是可以直接搬到生产的多租户服务。上面六条加固是一个”可以开始用”的最低集合,不是完备的安全方案。
  • 检索质量的上限由知识库决定。混合检索能改善召回,但如果库里本身没有正确内容,它只会把错误内容排得更靠前——第三节那个顺序 bug 就是这类问题的微观版本。
  • 技能的正确性没有自动校验。 “这次执行成功了”不等于”这条技能是对的”。一个只在特定数据上成功过的流程,被沉淀成通用技能后换个输入就会失败——这需要技能里显式记录前提条件。
  • 自建外部检索的成本是持续的。它换来可控与不外发,也换来”多一个东西会坏”。

(顺带说明两处没展开的操作:外部检索我用的是自建的 SearXNG [4] 而不是第三方搜索 API,理由是请求可审计、内部问题原文不外发、内外结果可在同一条链里合并——代价是多一个要维护的服务。写回通道上,Wiki.js 2.x 的 REST 端点全部 404,唯一可用入口是 /graphql [3];用内省先问服务端有哪些 mutation、需要哪些参数,比查文档更快也更准,因为文档版本经常和服务版本不一致。这两件事各值得单独一篇。)

7. 结论:最贵的代价是把错的做法记成经验

回到开头那个问题。

这个闭环给我最大的收获,是它逼着我回答一个平时可以回避的问题:

当一个系统开始修改自己的经验来源时,你凭什么相信它下一次的判据?

我现在的答案是:先把缰绳装上,再让它跑。

一个不会自我修改的系统,边界失效的代价是一次任务;一个会自我修改的系统,边界失效的代价是它以后所有的判断。

这也是我最终把”自动沉淀”从默认开启改成默认关闭的原因——自动化在这里省下的那一次确认,恰恰是最不能省的那一次。

参考文献

[1] Cormack, G. V., Clarke, C. L. A., & Buettcher, S. (2009). Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods. SIGIR 2009. DOI: 10.1145/1571941.1572114 —— RRF 的原始文献。

[2] PostgreSQL 文档 · 数组函数 array_position:https://www.postgresql.org/docs/current/functions-array.html(2026-10-11 实测可达)

[3] Wiki.js 官方文档 · GraphQL API:https://docs.requarks.io/dev/api(2026-10-11 实测可达)

[4] SearXNG 项目仓库:https://github.com/searxng/searxng(2026-10-11 实测可达)

关于代码片段的核验说明:第 3 节的检索与第 5 节的三处缺陷,引用的是我自己部署的一套服务代码,它不在公开仓库里,因此读者无法直接核对。我能提供的替代核验是:把第 5 节的三个片段放在一起看,它们的缺陷都不依赖具体实现细节——只校验首 token、默认自动写回、替换先于分词,这三条在任何同类实现里都能被独立判断;第 3 节那个 ORDER BY 的结论也可以在一分钟内复现:把一段带 = ANY(...) 的查询去掉排序,看返回顺序是否还跟着传入数组。

评论

填个昵称就能发,不需要注册或登录