让 Agent 自己长技能:一个"检索 → 计划 → 执行 → 沉淀"闭环,和它暴露出的三处安全缺陷
0次浏览0条评论0个赞
假设你打开知识库搜一个问题,拿到 5 条结果,把第 1 条喂给模型——它答得还行。但你不会注意到:这 5 条其实没有按相关度排序,真正最相关的那条排在第 4 位。
这就是”看起来对”的缺陷:不报错,只是让你持续拿到次优结果。而在我做的这个”agent 自己长技能”的闭环里,这类缺陷的代价被放大了一档。
让 agent 自己长技能,真正的风险在于它把错的做法记成经验:技能库同时是它自己的判据来源,错一次会被记住很多次。
我把这个闭环做通了:检索、计划、白名单执行、成功后把做法蒸馏成技能写回同一个知识库。下面先说这个闭环怎么跑通,再说它暴露的三处缺陷,最后说为什么这类缺陷值得单独记录。通读自己写的代码之后,我找到三处会让它从”效率工具”变成”风险源”的设计缺陷——它们的形状是同一种:检查存在,但检查的层级与风险发生的层级不一致。
1. 闭环:五个动作,和它为什么真的闭上了
上一节给了主线,这一节先看闭环本身的形状。
一个任务进来,走的路径是:
- 检索知识:同时查内部知识库和外部互联网;
- 总结分析:让模型从检索结果里提炼核心流程;
- 生成可执行步骤:输出结构化的命令列表与参数;
- 白名单执行:逐条执行,任一条失败就停下;
- 成功后沉淀:把这次成功执行蒸馏成”技能定义”(名称、参数、执行器、步骤),写回知识库。
第 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 有四个特征,让它特别容易活下来:
- 不报错:SQL 合法,结果条数正确,分数正确;
- 只在多条命中时显形:只有一条命中时,顺序无所谓;
- 人最容易忽略的恰恰是顺序:拿到 5 条结果,你会核对内容和分数,很少去核对”它们是不是按分数排的”;
- 它不影响”能不能答对”,只影响”答得好不好”:最相关的那条可能排在第 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. 我会怎么加固
三处缺陷是同一个形状,所以加固也按这个形状来——从最危险的默认值开始。
按”先关掉最危险的默认值,再谈精细控制”的顺序:
- 白名单只放具体命令,不放解释器。需要复杂逻辑时,写成脚本放进固定目录,白名单里只允许那个脚本路径——把”能力”固化成一个个可审计的入口,而不是留一个通用的执行器。
- 自动沉淀默认关闭。改成”生成候选技能 → 人工确认后入库”。这会牺牲一部分自动化,但对一条会改写系统可信来源的链,人工确认是值得的。
- 技能条目必须带来源与版本。哪次执行总结出来的、由哪个模型总结、原始命令与输出是什么——否则无法回溯”这条经验怎么来的”,也无法在发现它错时批量撤销。
- 参数先分词、再替换。把模板先解析成 token 列表,占位符作为独立的一个 token 被值替换,而不是先拼字符串再分词。这样参数值永远是值,不可能变成语法。
- 执行环境要有边界。默认断网、默认只读挂载所需数据卷、限制执行时长——让”执行错了”的后果被环境限制住,而不是只靠命令白名单。
- 外部内容的信任级别要低于内部内容。检索结果里混着互联网抓来的文本,而互联网文本是不可信输入;把它和内部知识不加区分地喂给”生成可执行计划”的模型,等于把提示注入的入口直接接到执行器前面。外部内容应只作为参考上下文,不参与生成可执行步骤。
第 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(...) 的查询去掉排序,看返回顺序是否还跟着传入数组。





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