标签归档:Agent

聊聊 Harness:从 Agent 到组织

我们在落地 Agent 时面临的核心矛盾,是大模型的概率生成机制与工程系统所需的绝对确定性存在天然冲突。要获取大规模、可维护且值得信赖的代码,必须在系统外围构建 Harness。Harness 的本质是将不确定性转化为确定性。

提高信任度和可靠性需要极度压缩 Agent 的解决方案空间。我们必须放弃让模型「生成任何内容」的灵活性,转而采用包含大量技术细节的提示、规则和框架。特定的架构模式、强制执行的边界以及标准化的结构,构成了这套护栏的物理基础。

当前越来越多的团队在持续快速的产生代码,而这些演示很好看,当真的进入整个软件生命周期中,就会产生混乱,当越来越多的人随着时间的推移在仓库中堆砌代码,组织就开始堆人进行 review、反复返工,最后 AI 的吞吐量被人类注意力卡死,表面上用了 Agent,实际产能没上去,维护成本还更高。

当然,这是一种结果,也有人在过程中不停的构建基建,做 Harness 工程,整个代码不再是无序的扩张。从这个逻辑来讲,harness 的作用是把大模型输出从概率事件压回工程确定性的系统设计

Agent 的 Harness

harness 是什么

很多人把 harness 比作一个操作系统,模型是 CPU,但我觉得并不是。

如果模型真的是 CPU,那它接收指令后的执行结果应该是绝对严格且可预测的;但大模型本质上是一个概率引擎,它在潜空间里做的是模式匹配与概率生成。因此,harness 并不是像操作系统那样去调度底层硬件资源或分配内存,它更像是一套概率过滤器和对齐机制。它依靠纯粹的工程手段,把模型那种发散的、充满不确定性的「创造力」或「幻觉」,强行压缩进一条狭窄、严谨且符合人类预期的流水线里。

这种工程逻辑在实践中,体现为无处不在的防御性设计和反馈闭环。当模型吐出一串代码或一个决策时,harness 并不负责直接「运行」它,而是负责「质检」和「纠偏」。它通过静态检查、架构规则扫描、自动化测试和沙箱验证,把模型给出的「大概率正确」转化为工程上非黑即白的「通过或驳回」。正是这种让概率不断撞击确定性规则的过程,才使得最终沉淀到代码库里的产物是安全、可控且符合系统长期利益的。

harness 解决确定性问题的终极目的,是为了在系统中建立无需人工干预的信任,从而真正释放 AI 的吞吐量。如果没有这套逻辑,模型生成的代码越多,人类审查的负担就越重,整个组织的运转速度依然会被人类的注意力瓶颈卡死。

Martin Fowler 的博客中发表了 Thoughtworks 的技术专家的一篇文章,将 OpenAI 文章中所描述的 harness 分为三个方面:

上下文工程

上下文工程需要做到动态与静态的交织

单纯依赖超长 Prompt 无法解决复杂工程问题。上下文工程的核心在于构建代码库中持续增强的知识库,并打通 Agent 对动态上下文的访问路径。

静态知识库定义了系统的基础法则。我们将领域模型、API 契约和历史架构决策文档化,作为 Agent 初始化的基线上下文。动态上下文决定了 Agent 在运行时的决策质量。系统需要将实时的可观测性数据、测试覆盖率报告甚至浏览器导航状态,实时注入到 Agent 的工作流中。缺乏动态上下文的 Agent 就像蒙眼狂奔的打字机,产出的代码在语法上完美,在逻辑上完全脱离系统现状。

架构约束

架构约束是确定性的防线。

完全依赖 LLM 进行自我反思和代码审查,在生产环境中极度危险。架构约束必须由确定性的自定义代码检查器和结构测试来强制执行。

我们通过静态分析工具拦截不合规的依赖调用,利用 AST(抽象语法树)解析确保代码分层符合规范。当 Agent 试图在 UI 层直接发起数据库连接时,确定性的检查器会立即阻断该行为,并将具体的错误堆栈和修复路径作为反馈输入给 Agent。这种混合架构确保了系统的底线由死板的规则守卫,Agent 的创造力被严格限制在安全的沙盒内。

垃圾回收

垃圾回收主要是用于对抗代码熵增。

完全自主的智能体引入了代码库衰败的新问题。Agent 会精准且不知疲倦地复现代码仓库中已存在的模式,包含那些不均衡或不够理想的遗留设计。随着时间的推移,这种行为不可避免地导致系统架构漂移。

最初,人类开发者试图手动处理这个问题。团队过去每周五要花费 20% 的时间清理「AI 残渣」。这种依赖人力的做法毫无可扩展性。

我们将资深工程师的主观品味转化为机械规则,提炼为「黄金原则」并直接编码到代码仓库中,建立了一个循环清理流程。我们强制要求使用共享的实用程序包,禁止手工编写零散的辅助工具,确保不变式集中管理。我们严禁使用猜测性的数据探测,强制验证边界或依赖类型化的 SDK,防止 Agent 基于虚幻的结构进行构建。

系统定期运行一组后台 Agent 任务,扫描代码库中的偏差、更新质量等级,并发起有针对性的重构 Pull Request。这些 PR 大多可以在一分钟内完成审查并自动合并。这套机制的功能等同于内存管理中的垃圾回收。技术债务如同高息贷款,通过高频的微小重构不断偿还,远胜过让债务累积到系统崩溃。人类的架构品味一旦被捕获并规则化,就会无情地应用于每一行代码,每天自动发现并消灭不良模式。

AI Agent Harness 的工程化落地

从几个流行的框架来看,主要是从流程强化、规格沉淀、任务编排等逻辑上来做事情。

将这些逻辑拆开可以分为四个维度:

上下文工程

上下文工程主要是在规范层解决问题,其主要解决的「规则文件失控」的问题,实现规格沉淀与对齐,以及上下文工程的可控。

之前,我们习惯把所有规范塞进类似于单个 .cursorrules 文件,导致 AI 上下文过载且容易忽略细节。这一层落地的第一步是建立结构化、按需加载的规范体系。主要做到如下的点:

  • 规范模块化:将系统架构、数据库规范、错误处理等拆分为独立的结构化文档。
  • 按需检索:AI 不需要每次都通读所有规范,而是根据当前所处的任务阶段,动态检索并加载所需的上下文。
  • 任务记忆隔离:为每个独立任务建立物理隔离的工作区和日志。AI 每次开启新会话时,只读取当前任务的精确记忆,既解决了“跨会话失忆”,又屏蔽了无关信息的干扰。

以 Trellis 框架为例,Trellis 摒弃了单一庞大的全局提示词文件,而是采用 spec/ 目录将规范模块化(如拆分为 database-guidelines.md)。在执行任务时,它利用 tasks/ 目录下的 JSONL 配置文件,让 Agent 动态检索并按需加载上下文。

架构约束

架构约束的核心逻辑:用代码约束代码,实现闭环自愈。 口头约定或纯文本规范在 AI 面前是脆弱的,它极易为了「跑通逻辑」而破坏架构分层。

  • 规则代码化:将核心的架构依赖规则(例如“前端组件严禁直接调用数据库”)编写为静态分析脚本或自定义 Linter。
  • 带解释的强阻断:在代码提交或验证阶段强制执行这些拦截器。关键在于,报错信息不能仅仅是「检查失败」,必须输出高度结构化的指导:明确告诉 AI“为什么违反了规则”以及“正确的做法是什么”。
  • 自动修复:AI 读取到结构化的报错指导后,能够自动理解并修正代码,形成无需人类介入的自愈闭环。

反馈循环

核心逻辑:降噪处理,防范死循环。 LLM 的注意力会被长篇大论的日志(如几千行的覆盖率输出)稀释注意力,从而忽略真正致命的错误。 因此我们需要做到:

  • 零输出原则:改造验证脚本。如果测试通过,脚本应保持完全沉默;如果失败,只输出精简的错误堆栈和失败原因。
  • 强制验收清单:在 AI 试图标记任务「已完成」之前,系统应强制拦截,要求其对照需求文档逐项确认边界条件。
  • 防死循环干预:设定重试阈值。如果 AI 对同一文件连续修改多次且测试依然失败,系统应主动中断并强制其回滚代码、重新审视需求,防止 AI 陷入无效的「幻觉修 Bug」循环。

熵管理

熵管理主要是阻断「坏模式」的指数级扩散

核心逻辑:快速偿还技术债。 AI 复制坏代码的速度是指数级的。一旦允许一个临时的妥协方案合入主分支,AI 会在极短时间内将其复制到整个代码库。

  • 高频垃圾收集:彻底放弃“集中清技术债”的传统做法。每天必须安排固定时间,专门 Review AI 生成的代码(人工或 AI 自动),及时识别新引入的坏模式。
  • 规范资产的动态演进:一旦发现坏模式,立即让 AI 深度分析根因,并自动将正确的防范规则更新到规范库中
  • 团队级免疫:由于规范库与代码同源管理(存在于 Git 仓库中),当这段新规则被提交后,团队其他成员拉取代码时,他们的 AI 助手就能立刻“学会”这个新技能。这把偿还技术债的动作,变成了每天自动化、可积累的系统进化。

以 Cursor 为例,可以更新 Team Rules

组织级 Harness

聊完 Agent 的 Harness,再聊一下组织的。

人的角色已经变了

大家都知道康威定律,简单来说就是:设计系统的组织,其产生的设计受限于这些组织的沟通结构。

而系统设计到最后,也一定会遇到一个问题:谁来定义规则,谁来解释例外,谁来承担后果。

以前的软件开发分工相对稳定。PM 写需求,设计出稿,前后端分别实现,测试验证,运维发布。大家各自占一段链路,边界虽然有摩擦,但总体清楚。

AI 进来以后,边界开始模糊。

PM 已经可以直接产出前端原型,很多时候产出的还不是静态图,而是真能跑的页面代码。设计师也不再只是给稿子,很多交互和组件约束可以直接沉淀成生成资产。前端工程师花在纯页面搭建上的时间下降,开始更多介入状态管理、交互抽象、可维护性收拢。后端和算法也更早被拉进来,因为很多 AI 生成的原型一开始就会碰到真实数据和能力边界。

这是现在很多团队正在进行的转型。

如果组织还按旧的分工运转,Agent 会把协作缝隙快速放大。

组织级 Harness 要管什么

我理解的组织级 harness,重点在三件事:

  1. 定义新的协作接口
  2. 重新分配注意力
  3. 把责任从「谁写了代码」改成「谁定义了系统」

协作接口要前移

以前很多问题可以留到开发阶段再对齐。现在不行。

因为 PM 通过 AI 已经能直接产出前端代码,需求不再是文字说明,而可能是一个可交互原型;设计规范也不再只是 Figma 标注,而是可以半自动映射到组件约束;后端接口能力如果不提前讲清楚,前面的生成很容易一路偏到错误方向。

所以组织里的评审必须前移,重点也得改。

过去的需求评审,很多时候在讨论功能要不要做。现在要多讨论三件事:

  • 验收标准到底是什么
  • 哪些边界不能突破
  • 哪些部分允许先用原型推进,哪些必须工程化收拢后才能上线

这几个东西不提前定,后面会出现一个很常见的问题:原型阶段看起来进展飞快,进入工程化后才发现返工巨大。

注意力要重新分配

我现在越来越少鼓励资深工程师花时间逐行抠低风险代码。

这不是说 review 不重要,而是注意力要贵着用。

在 Agent 环境里,重要的工作变成了:

  • 定义验收标准
  • 设计架构边界
  • 提炼黄金原则
  • 识别系统性失败信号
  • 决定哪些异常值得阻塞主流程
  • 审核高风险改动和高影响面重构

反过来,低风险、重复性、局部性的东西,应该尽量交给自动化校验和后台清理任务。

如果一个组织还在让最贵的人力去看大批格式化差异、小工具改名、重复样板代码,那 harness 基本等于没有。

责任归属要重写

在 AI-Native 组织里,谁对结果负责?

PM 产出了页面代码,前端做了工程化收拢,Agent 自动补了测试,清理 Agent 又改了一轮共享工具。最后线上出问题,算谁的?

如果这个问题没有明确答案,团队会很快进入防御状态。每个人都怕接 AI 产出的锅,于是流程开始重新变重,所有人都试图把责任往后传。

所以组织级 harness 一定要明确责任模型。

可以按三层分:

  • 需求责任:谁定义了目标与验收标准,谁负责需求正确性
  • 架构责任:谁定义了边界、模式和约束,谁负责系统一致性
  • 发布责任:谁决定进入生产环境,谁负责风险接受

不要再执着于「谁手写了这行代码」。就像团队管理一样,最后拍板的人担责。

可落地的 AI-Native 研发流程

以下为我们当前在跑的流程:

需求生成

第一步由 PM 主导,但交付物不再只是 PRD,而是带验收标准的可运行原型

但是,原型代码不等于可直接上线代码。它的价值是澄清需求、暴露分歧、提前感知交互复杂度。

所以 PM 可以生成,但不能默认拥有工程决策权。最终所有的代码都需要前端工程师构建的工具链条,以及 AI 和人工的审核及合入。

联合评审

第二步是全员参与的需求评审与架构设计。设计、前端、后端、算法都要尽早介入。

这个阶段重点不是抠实现细节,而是确定:

  • 用户路径是否成立
  • 数据流怎么走
  • 状态边界怎么划
  • 哪些能力用现有服务承接
  • 哪些模块需要新增抽象
  • 风险点在哪
  • 验收怎么自动化

这这一步的产出结构化进仓库,因为后面它会直接成为 Agent 的约束输入。

工程收拢

第三步是工程化整合。这个阶段前端、后端、算法开始把前面的原型和需求收敛进正式系统。

这里 Agent 会大量参与,但人类不能退出。重点工作包括:

  • 把原型重构进现有组件和模块体系
  • 校正状态管理、错误处理、埋点、权限、监控
  • 对接真实接口和算法能力
  • 补齐类型、边界验证和回归测试
  • 处理跨模块影响面

这一段最考验 harness,因为原型代码最容易带着局部最优、全局失真、风格漂移的问题冲进主仓。

自动验证与灰度

最后一步是自动化测试、灰度发布和反馈回收。

这一步先由专门的工程团队来负责,加入部分的 AI 成分,固化系统。

从 Agent 到组织,真正难的是控制系统

很多人以为 AI 落地的核心挑战在模型能力、成本或者工具接入。我现在看,最大挑战更集中在三个词:环境、反馈回路、控制系统。

环境决定 Agent 看到了什么、能做什么、不能做什么。

反馈回路决定错误会被放大,还是会被系统吸收成改进信号。

控制系统决定生成能力增长之后,组织是变得更稳,还是更乱。

这三个东西做不好,模型再强也只是更快地产生问题。

做得好,哪怕模型能力没到最顶尖,系统一样能稳定进化。因为工程上真正稀缺的,从来不是一次惊艳输出,而是长期重复地产出靠谱结果。

组织的 AI-Native 化

组织的 AI-Native 化也是慢慢进货,逐步推进的,先从小范围试起,再根据结果不断调整规则和流程。并且各家有各家的风格和气质。

第一,选一条链路打透。不要一开始就全组织铺开。先找一个协作关系清楚、反馈周期短、风险相对可控的场景,比如中后台、运营工具、内部系统,或者低风险服务改造。重点不是让 AI 多写代码,而是先验证:信息怎么给、边界怎么定、错误怎么发现、问题怎么清理。

第二,先改规则,再谈效率。很多团队一上来就问产能能提升多少,但更重要的是:规则有没有沉淀下来,错误能不能回流,坏模式能不能及时发现并清掉。如果这些没做好,所谓提效往往只是把问题推后,甚至把混乱放大。

第三,把人的位置往上移。资深工程师要逐渐从大量写代码,转向定规则、画边界、看反馈;技术管理者要从盯人和排期,转向设计流程、分层风险、明确责任;产品可以更早参与原型,但不能越过工程判断。

组织真正变成 AI-Native,不是因为每个人都在用 Agent,而是协作方式已经围绕 Agent 被重新设计过。

模型当然重要,但不是决定性因素。真正拉开差距的,是谁先意识到:Agent 不是一个更快的开发者,而是一个高吞吐的生产单元。它会放大环境本身。规则清楚,它就放大规则;流程混乱,它就放大混乱。

所以到最后,harness 这件事谈的根本不只是 AI。

谈的是工程纪律怎么重新编码。

谈的是组织协作怎么重新布线。

谈的是我们怎么把概率生成系统,放进一个仍然要求长期维护、长期演进、长期负责的软件世界。

这是我理解的「从 Agent 到组织」。

以上。

对最近 AI 落地工程实践的一些想法和思考

最近和小区某上市公司的 CFO 喝茶聊 AI,在过程中思维和实际场景的碰撞,记录如下:

穿透复杂的表象,当前 LLM 的底层运行逻辑其实非常单一:它本质上是一个自回归的序列生成器,根据已有的上下文,计算词表中每一个 token 出现的概率分布,然后从中采样出下一个 token。

但这里的「概率」绝非毫无逻辑的随机掷骰子。 这种概率分布,是模型在海量预训练数据中内化的语言规律、世界知识以及逻辑推理能力的数学投影。通过多层 Transformer 网络与注意力机制(Attention),模型在极高的维度上完成了对上下文语义的深度解析与特征关联,从而将符合人类逻辑、契合当前语境的 token 赋予极高的概率权重。它是在用统计学的方式,重现人类的逻辑推理过程。

然而,无论其内部的概率计算多么精密,从软件工程的宏观视角来看,我们本质上依然是在传统的确定性系统中,强行引入了一个基于概率采样的非确定性组件。

传统软件工程建立在严格的确定性之上。输入特定的参数,经过固定的业务逻辑,必然得到预期的输出。现在我们将核心逻辑交由概率模型处理,相同的输入在不同的时间点,可能会产生完全不同的输出路径。

幻觉无法被根除。它是自回归模型的内生特性,是概率采样的必然产物。我们在进行系统架构设计时,必须将幻觉视为系统的常态。试图通过修改 Prompt 来彻底消除幻觉,在工程上徒劳无功。我们需要在系统边界处建立起拦截机制,用确定性的规则去兜底概率模型的不确定性。

容错度决定落地

当前商业化落地最顺畅、ROI 最高的场景,全部集中在高容错度领域。写行业报告、生成营销文案、文生图、视频生成、游戏 NPC 对话。这类场景的核心特征在于缺乏绝对的客观标准。

在内容创作领域,模型偶尔的逻辑发散会被用户视为创造力。工程团队不需要在接口的绝对可用性和输出的绝对准确性上死磕,只需要保证底线的内容安全和合理的响应延迟。系统可用性达到 95% 就能让用户产生极强的获得感。

一旦进入低容错度场景,工程实现的复杂度会呈指数级上升。医疗诊断、工业控制、核心交易链路。在这些领域,0.1% 的幻觉率都会导致灾难性的业务后果。我们在评估一个 AI 项目是否立项时,首要考量指标就是业务场景的容错底线。容错度越低,外围需要的确定性校验代码就越厚重,最终会导致系统的维护成本远超 AI 带来的效率提升。

知识外挂 RAG

RAG 的出现是为了解决模型内部知识更新滞后和私有数据隔离的问题。其核心原理是将外部文档切片、向量化,在用户提问时检索相关切片,拼接到 Prompt 中作为上下文喂给大模型。

在实际的工程环境里,RAG 的核心瓶颈在检索链路。切片策略直接决定了召回质量。按固定 token 长度切分会破坏语义完整性,导致关键信息被腰斩。按标点符号或段落切分会导致切片长度方差过大,影响向量化模型的表达能力。我们在生产环境中通常需要针对不同格式的文档编写定制化的解析器,将 PDF 或 Word 还原为结构化的文档树,再基于文档树的层级进行语义切片。

单一的向量检索在面对专有名词和长尾词汇时表现极差。我们必须采用混合检索架构:稠密向量检索加上稀疏词表检索。向量检索负责语义泛化,处理同义词和模糊表达。词表检索负责精准匹配产品型号、人名和内部项目代号。混合检索引入了多路召回合并的问题,通常需要引入倒数秩融合算法来重排结果。系统复杂度和查询延迟会成倍增加。

数据清洗占据了 RAG 项目 80% 的研发精力。直接将企业内部的原始文档灌入向量数据库,最终的问答准确率通常不到 40%。文档中存在大量的废话、过期的流程规范以及相互冲突的条款。垃圾进,垃圾出。我们在构建知识库之前,必须通过脚本和人工介入,对语料进行严格的去重、降噪和结构化提取。

工具调用确定性

为了弥补概率模型的缺陷,我们需要引入确定性的工具。Function Calling 机制本质上是给 LLM 接上双手。模型负责理解自然语言意图并提取结构化参数,具体的业务逻辑交由传统的确定性脚本执行。

工具调用的工程难点在于参数提取的稳定性。当注册的工具数量超过十个,或者参数结构嵌套层级过深时,模型的输出格式极易崩溃。我们在中间层必须加入严格的 Schema 校验机制。一旦校验失败,需要截断错误信息并触发重试。重试次数上限通常设定为 3 次,继续增加会耗尽上下文窗口并导致请求超时。

多轮工具调用会带来严重的延迟问题。模型每决定调用一次工具,都需要经历一次完整的网络请求和推理过程。如果一个复杂任务需要串行调用三个工具,用户的等待时间会轻易突破 10 秒。我们在架构设计时,需要尽可能将细粒度的 API 聚合成粗粒度的宏接口,减少模型与业务系统的交互频次

Agent 架构的脆弱性与状态管理

多智能体(Multi-Agent)架构在技术社区被过度神话。多个大模型相互协作、自主规划任务的 Demo 看起来非常惊艳。在真实的工业场景中,完全由 LLM 自主驱动的 Agent 链路极其脆弱。

误差会在多步推理中被迅速放大。假设单个 Agent 节点的输出准确率为 90%,一个包含五个节点的串行任务,最终的成功率会暴跌至 59%。任何一个节点的幻觉都会导致后续链路彻底跑偏。

我们在生产环境中构建复杂任务流时,坚决摒弃由 LLM 自主决定执行路径的黑盒模式。控制流必须由传统的有向无环图(DAG)或状态机来接管。LLM 仅仅作为状态机中的一个计算节点,负责处理非结构化数据的理解和生成。节点与节点之间的状态流转、条件判断、异常重试,全部由确定性的代码实现。这种设计牺牲了系统的灵活性,换取了业务系统必须具备的稳定性和可观测性。

非确定性系统的测试与监控

非确定性系统的测试与监控,是传统软件工程团队转型 AI 开发时遇到的最大痛点。传统的单元测试基于断言,期望输出是固定的字符串或数值。面对 LLM 每次都不一样的回答,基于精确匹配的 CI/CD 流水线会全线崩溃。

我们重构了整个测试评估体系。引入 LLM-as-a-Judge 机制,使用一个能力更强、参数规模更大的模型来评估业务模型的输出质量。评估维度被拆解为相关性、事实一致性、格式合规性等具体指标。在每次模型版本迭代或 Prompt 修改后,必须在包含上千个真实业务 Case 的黄金数据集上运行自动化评估。只有各项指标的波动在可控范围内,才能进行灰度发布。

在监控层面,传统的 APM 工具无法满足需求。我们需要采集每一个请求的 Prompt 模板版本、输入变量、输出结果、Token 消耗量以及推理延迟。这些数据是后续进行 Bad Case 分析和模型微调的唯一原料。针对 Token 消耗的监控直接与业务成本挂钩。我们会在网关层设置严格的并发限制和预算熔断机制,防止恶意请求或死循环调用导致账单失控。

两种范式的碰撞

AI First 与 AI 辅助是完全不同的架构逻辑。

AI 辅助是在现有系统中打补丁。主干流程依然是传统的表单和按钮,AI 作为一个侧边栏或悬浮窗存在,提供总结、翻译、润色功能。开发成本极低,对原有系统无侵入。用户在遇到问题时,可以选择性地向 AI 求助。

AI First 要求重构整个交互形态和底层流转逻辑。系统不再依赖预设的菜单树,由 LLM 充当中央路由。用户的自然语言输入直接驱动底层状态机流转。这要求所有内部 API 具备极高的自描述能力,业务逻辑必须高度解耦。我们在推进 AI First 架构时,面临的最大阻力通常来自老旧系统的技术债。历史遗留的紧耦合代码根本无法被封装成独立的工具供模型调用。

财务场景的拆解

财务场景是典型的低容错度、高确定性要求的领域。将概率模型直接应用于财务核心链路会引发严重的合规风险。可落地的切入点集中在外围的非结构化数据处理和信息流转环节。

发票与报销单据的信息抽取是一个高价值场景。传统 OCR 结合正则匹配在面对版式多变的票据时维护成本极高。引入大模型进行多模态信息抽取,将非结构化的图片或 PDF 转换为结构化的 JSON 数据。抽取后的数据必须经过传统规则引擎的二次校验,例如金额试算平衡验证、税号合规性检查。模型在这里承担的是「粗加工」角色,最终的业务落库动作依然由确定性代码把控。

财务制度问答可以大幅降低沟通成本。基于企业内部报销规范构建 RAG 系统。员工在提单前通过自然语言查询报销标准。这里的 RAG 必须严格限制模型的发散,Prompt 中需强制要求「仅根据检索到的内容回答,未提及的内容直接回复不知道」。为了防止模型编造财务政策,我们会在输出层增加一层文本相似度校验,确保模型的回答与检索到的原文保持高度一致。

财务分析报告初稿生成也是一个可行的方向。将结构化的财务报表数据通过代码转换为文本描述,作为上下文喂给模型,让其生成趋势分析和异常波动提示。模型在这里仅作为「翻译官」和「排版员」,不参与任何数值计算。所有的同比、环比计算必须在传统代码层完成,将计算结果以明确的数值形式提供给模型。让 LLM 去做算术题是工程上的反模式。

数据隐私在财务场景中是不可逾越的红线。公有云 API 无法满足审计要求。我们通常需要采用本地私有化部署的开源模型。7B 到 14B 参数规模的模型经过量化处理后,可以在单张消费级显卡上流畅运行。通过针对财务语料的微调,这些小模型在特定信息抽取任务上的表现可以持平甚至超越千亿参数的通用大模型。私有化部署带来了硬件采购和模型运维的额外成本,需要在项目初期进行严格的 ROI 测算。

以上

Claude Code 的 SKILLS 技能渐进式披露实现原理解析

SKILLS 和 渐进式披露 是 A 家最早提出来的方案,也是 OpenClaw 火了后大家一直讨论的哪个技能好用很核心的强依赖的实现逻辑。

如果把 Claude Code 的 skills 理解成一堆 prompt 文件,后面的很多设计都解释不通。

从其源码实现来看,会发现它在解决的核心问题是:怎么让模型保留足够强的技能召回能力,同时又不把常驻上下文撑爆。

这件事说穿了就是五个字:渐进式披露

大概的逻辑是:

  • 先告诉模型「系统里存在 skills 机制」。
  • 再告诉它「当前有哪些 skill 名称和简短说明」。
  • 等它真的决定调用某个 skill 时,再把正文、权限、hooks、模型覆盖、附加工具权限这些重内容展开。
  • 如果某些 skill 还和路径、目录、文件类型绑定,那就继续往后拖,拖到模型真的碰到对应文件时再激活。

这是一个优雅且干净的工程化设计。它没有发明一套复杂到难以维护的 skill runtime,也没有把所谓智能寄托在黑盒检索器上,而是先把「披露成本」这件事控制住。

我们按工程实现往下拆:

  • skill 在系统里到底被建模成什么
  • 多来源 skill 是怎么统一装配的
  • 渐进式披露具体分了哪几层
  • 条件激活和动态发现是怎么接进文件操作链路的
  • inline 和 fork 两条执行路径分别解决什么问题
  • 这套设计真正适合什么场景,代价又是什么
  • 如果要在自己的 Agent 里复刻,最短落地路径应该怎么走

一、先看 skills 在系统里被建模成什么

Claude Code 里,skill 最终会被统一建模成 Command,而且类型是 prompt

最核心的构造函数是 createSkillCommand:

return {
type'prompt',
  name: skillName,
  description,
  hasUserSpecifiedDescription,
  allowedTools,
  argumentHint,
  argNames: argumentNames.length > 0 ? argumentNames : undefined,
  whenToUse,
  version,
  model,
  disableModelInvocation,
  userInvocable,
  context: executionContext,
  agent,
  effort,
  paths,
  contentLength: markdownContent.length,
  isHidden: !userInvocable,
  progressMessage: 'running',
  userFacingName(): string {
    return displayName || skillName
  },
  source,
  loadedFrom,
  hooks,
  skillRoot: baseDir,
async getPromptForCommand(args, toolUseContext) {
    ...
    return [{ type'text', text: finalContent }]
  },
}

这段代码说明有几个关键点:

  • skill 不是特殊 runtime object,而是 prompt command
  • skill 本体是 getPromptForCommand() 生成的一组文本 block
  • skill 可以带:
    • allowedTools
    • model
    • effort
    • paths
    • hooks
    • context: inline | fork
  • skill 的调用结果,不是「执行一段脚本」,而是把 skill 展开成后续对话消息,或者 fork 成子代理执行

如果我们自己做 Agent,建议参考。skill 不要单独发明一套 DSL runtime,直接把它抽象成「可延迟展开的 prompt 命令」就够了。

二、skills 的来源有哪几类

skills 并不只来自一个目录。getSkills() 会把多个来源统一聚合。[commands.ts] commands.ts#L353-L398

const [skillDirCommands, pluginSkills] = await Promise.all([
  getSkillDirCommands(cwd)...
  getPluginSkills()...
])
const bundledSkills = getBundledSkills()
const builtinPluginSkills = getBuiltinPluginSkillCommands()

然后 loadAllCommands() 再把这些东西和 workflow/plugin/内建命令一起合并。[commands.ts] commands.ts#L445-L469

也就是说,skills 的来源至少有:

  • bundled skills
  • 磁盘上的 /skills/
  • plugin skills
  • builtin plugin skills
  • 兼容旧 /commands/ 目录加载进来的 prompt commands

SkillTool 根本不需要知道 skill 来自哪里。只要最后是 prompt command,就能走统一调用路径。

三、skills 的「渐进式披露」分 5 层

1)第一层:系统提示只声明「技能机制存在」

系统提示里不会把所有 skill 正文直接塞进去。它只给一个能力声明,告诉模型:

  • 用户说 /<skill-name>,其实是在指 skill
  • 可以用 SkillTool 去执行
  • 不要乱猜,只能调用列出来的那些

这段在 [prompts.ts] prompts.ts#L353-L401:

hasSkills
  ? `/<skill-name> (e.g., /commit) is shorthand for users to invoke a user-invocable skill. When executed, the skill gets expanded to a full prompt. Use the ${SKILL_TOOL_NAME} tool to execute them. IMPORTANT: Only use ${SKILL_TOOL_NAME} for skills listed in its user-invocable skills section - do not guess or use built-in CLI commands.`
  : null

这一步只暴露了机制,没有暴露内容

2)第二层:只披露 skill 名称和短描述

真正给模型看的 skill 列表,是通过 getSkillToolCommands() 过滤出来的。[commands.ts] commands.ts#L561-L580

return allCommands.filter(
  cmd =>
    cmd.type === 'prompt' &&
    !cmd.disableModelInvocation &&
    cmd.source !== 'builtin' &&
    (
      cmd.loadedFrom === 'bundled' ||
      cmd.loadedFrom === 'skills' ||
      cmd.loadedFrom === 'commands_DEPRECATED' ||
      cmd.hasUserSpecifiedDescription ||
      cmd.whenToUse
    ),
)

这段有两个要点:

  • 只有 prompt 命令才能进 skill 列表
  • 并不是所有 prompt command 都自动暴露,至少得满足可描述性要求

也就是说,可执行集合对模型披露集合不是完全相同的。
Claude Code 在这里收了一刀,避免模型看到一堆没有描述、无法判断用途的技能。

3)第三层:列表本身还要走预算裁剪

skill 列表不是全量原文塞进 prompt,而是按预算压缩过的。核心逻辑在 [prompt.ts] tools/SkillTool/prompt.ts#L20-L171。

最关键的常量:

export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01
export const DEFAULT_CHAR_BUDGET = 8_000
export const MAX_LISTING_DESC_CHARS = 250

以及格式化逻辑:

return `- ${cmd.name}${getCommandDescription(cmd)}`

和预算裁剪:

if (fullTotal <= budget) {
  return fullEntries.map(e => e.full).join('\n')
}

如果超预算,就会:

  • bundled skills 尽量保留完整描述
  • 其它 skills 截断 description
  • 极端情况下退化成只发 - skill-name

这就是很典型的渐进式披露:先给最小可用索引,不给正文

4)第四层:列表还是增量下发,不是每轮全量重发

技能列表通过 skill_listing attachment 发给模型。发送逻辑在 [attachments.ts] utils/attachments.ts#L2669-L2752。

核心逻辑:

const newSkills = allCommands.filter(cmd => !sent.has(cmd.name))
...
for (const cmd of newSkills) {
  sent.add(cmd.name)
}
...
return [
  {
    type'skill_listing',
    content,
    skillCount: newSkills.length,
    isInitial,
  },
]

这个 sentSkillNames 机制说明:

  • 第一次发的是初始批次
  • 后面只发新增的 skill
  • resume 之后还会 suppress,避免重复污染上下文

然后 messages.ts 会把它包成系统提醒。[messages.ts] utils/messages.ts#L3763-L3772

return wrapMessagesInSystemReminder([
  createUserMessage({
    content: `The following skills are available for use with the Skill tool:\n\n${attachment.content}`,
    isMeta: true,
  }),
])

很多 Agent 会每轮把所有 tools / skills 全量重发,Claude Code 显然在认真控 token。 当然,如果技能不多,也可以直接全量发,不要过早优化。

5)第五层:真正的 skill 内容延迟到调用时才展开

直到调用 SkillTool,skill 的真实正文才会通过 command.getPromptForCommand() 生成。[SkillTool.ts] utils/processUserInput/processSlashCommand.tsx#L869-L920

这里才会发生:

  • $ARGUMENTS 替换
  • ${CLAUDE_SKILL_DIR} 替换
  • ${CLAUDE_SESSION_ID} 替换
  • markdown 内嵌 shell 执行
  • hooks 注册
  • 附加权限 attachment 注入
  • invoked skill 记录

换句话说,skill 的重内容、重权限、重上下文副作用,都是按需加载

四、除了延迟加载,它还做了「条件激活」

这也是渐进式披露的重要一层,而且很多人会漏掉。

1)带 paths frontmatter 的 skill,不会启动即暴露

getSkillDirCommands() 里会把 skill 分成两类:[loadSkillsDir.ts] loadSkillsDir.ts#L771-L803

if (
  skill.type === 'prompt' &&
  skill.paths &&
  skill.paths.length > 0 &&
  !activatedConditionalSkillNames.has(skill.name)
) {
  newConditionalSkills.push(skill)
} else {
  unconditionalSkills.push(skill)
}

然后 conditional skills 被先放进 conditionalSkills map,而不是直接进入模型可见集合。

这意味着:

  • 你定义了某个 skill 只适用于 *.tsx
  • 它不会在项目启动时就干扰所有任务
  • 只有模型真的碰到匹配文件时,这个 skill 才会被激活

2)激活时机挂在文件操作上

FileRead / FileWrite / FileEdit 三个工具里,都有两步副作用:

  • 发现上层目录里的 .claude/skills
  • 激活匹配当前文件路径的 conditional skills

比如 FileReadTool:[FileReadTool.ts] /tools/FileReadTool/FileReadTool.ts#L575-L591

const newSkillDirs = await discoverSkillDirsForPaths([fullFilePath], cwd)
...
addSkillDirectories(newSkillDirs).catch(() => {})
...
activateConditionalSkillsForPaths([fullFilePath], cwd)

对应的激活实现是 [activateConditionalSkillsForPaths] skills/loadSkillsDir.ts#L997-L1058:

const skillIgnore = ignore().add(skill.paths)
...
if (skillIgnore.ignores(relativePath)) {
  dynamicSkills.set(name, skill)
  conditionalSkills.delete(name)
  activatedConditionalSkillNames.add(name)
}

这一步非常像条件规则系统,而不是纯静态注册。
效果就是:技能集合会随着你读写哪些文件而变化

五、动态发现本身也是渐进式披露的一部分

除了 path-conditional activation,Claude Code 还支持目录级动态发现

1)启动时只加载一部分 skill 目录

getSkillDirCommands() 启动时会加载:

  • managed
  • user
  • project dirs
  • additional dirs
  • legacy commands

但它不会把所有嵌套目录里的 .claude/skills 一次性全扫出来。[loadSkillsDir.ts] skills/loadSkillsDir.ts#L638-L804

2)当模型碰到某个文件时,再向上走目录树找嵌套 skill

discoverSkillDirsForPaths() 会从当前文件的父目录开始,一路往上走到 cwd,查找 .claude/skills。[loadSkillsDir.ts] skills/loadSkillsDir.ts#L861-L915

while (currentDir.startsWith(resolvedCwd + pathSep)) {
  const skillDir = join(currentDir, '.claude''skills')
  ...
  await fs.stat(skillDir)
  ...
  newDirs.push(skillDir)
}

而且还做了两个非常实用的约束:

  • 已检查过的目录不会重复 stat
  • gitignored 目录里的 skills 不会静默加载

这个设计让:
技能跟着你进入子目录而出现,不跟整个仓库一起一次性曝光。

六、SkillTool 的调用链,实际上分 inline 和 fork 两条路

这是技能系统和普通 slash command 最大的不同之一。

1)调用前校验

SkillTool.validateInput() 会做:

  • 去掉前导 /
  • 检查 skill 是否存在
  • 检查是否 disableModelInvocation
  • 检查是否为 prompt 类型
    见 [SkillTool.ts] tools/SkillTool/SkillTool.ts#L355-L430

关键逻辑:

const commands = await getAllCommands(context)
const foundCommand = findCommand(normalizedCommandName, commands)
...
if (foundCommand.type !== 'prompt') {
  return {
    result: false,
    message: `Skill ${normalizedCommandName} is not a prompt-based skill`,
  }
}

2)权限检查

SkillTool.checkPermissions() 很细,除了 allow / deny 规则,还会对「只有安全属性的 skill」自动放行。[SkillTool.ts] /tools/SkillTool/SkillTool.ts#L433-L579

这个设计的意义是:

  • 简单 declarative skill 不必每次都弹权限
  • 带额外风险属性的 skill 要 ask user

3)inline skill:展开成后续对话消息

默认分支会走 processPromptSlashCommand()。[SkillTool.ts] tools/SkillTool/SkillTool.ts#L635-L644

getMessagesForPromptSlashCommand() 干的事情很丰富:[processSlashCommand.tsx] utils/processUserInput/processSlashCommand.tsx#L827-L920

  • command.getPromptForCommand(args, context) 得到真正 skill 正文
  • 注册 hooks
  • addInvokedSkill() 记录 skill 内容,供 compact 时恢复
  • 从 skill 文本里再抽 attachment
  • 增加 command_permissions attachment
  • 生成一批 messages

返回结构里最关键的是:

return {
  messages,
  shouldQuery: true,
  allowedTools: additionalAllowedTools,
  model: command.model,
  effort: command.effort,
  command
}

也就是说,inline skill 的本质是:
把 skill 变成一段新的上下文和权限修饰,然后让主对话继续跑。

4)fork skill:交给子代理跑,再把结果归还

如果 skill frontmatter 里声明 context === 'fork',就走 executeForkedSkill()。[SkillTool.ts] tools/SkillTool/SkillTool.ts#L622-L633

它会:

  • 构造子代理上下文
  • runAgent()
  • 收集 agent messages
  • 抽取结果文本
  • 最终返回 { status: 'forked', agentId, result }
    见 [executeForkedSkill] /tools/SkillTool/SkillTool.ts#L122-L290

这一步说明 Claude Code 已经把 skill 分成两类:

  • 知识/流程模板型 skill:inline 展开
  • 工作委派型 skill:fork 子代理执行

这个值得学一下。不是所有 skill 都应该展开在主上下文里。

七、结果返回逻辑

为什么它也算渐进式披露的一部分?

1)inline skill 的 tool_result

很轻

mapToolResultToToolResultBlockParam() 对 inline skill 的返回只是:

content: `Launching skill: ${result.commandName}`

见 [SkillTool.ts] tools/SkillTool/SkillTool.ts#L857-L862

也就是说,tool_result 本身不承载 skill 的全部结果。
真正有价值的内容在 newMessages 里,已经被送回主会话继续推理。

2)fork skill 的 tool_result

直接带最终结果

fork skill 返回的是:

content: `Skill "${result.commandName}" completed (forked execution).\n\nResult:\n${result.result}`

见 [SkillTool.ts] tools/SkillTool/SkillTool.ts#L848-L855

这是因为 fork skill 已经在独立上下文里把工作做完了,主线程要拿的是总结结果。

所以在 Claude Code 里,skill 结果返回不是单一模式,而是:

  • inline:返回「已加载 skill」,真正内容进主对话
  • fork:返回「子代理执行结果」

这也是一种披露控制。
不同执行语义,对结果暴露方式也不同。

八、如何简要实现

一个新 Agent,如何简要实现 skills 的发现、召回、调用、结果返回?

一个够用、够短、能落地的最小设计,不追求和 Claude Code 一模一样,但核心思路一致。

1)第一步:统一 skill 数据结构

最小结构建议这样:

type Skill = {
  name: string
  description: string
  whenToUse?: string
  contentLoader: (args: string, ctx: AgentContext) => Promise<string>
  allowedTools?: string[]
  model?: string
  effort?: 'low' | 'medium' | 'high'
  context?: 'inline' | 'fork'
  paths?: string[]
}
  • contentLoader 允许延迟展开
  • context 决定 inline/fork
  • paths 支持条件激活
  • allowedTools/model/effort 支持 skill 级上下文修饰

这和 Claude Code 的 createSkillCommand() 思路是一致的。[loadSkillsDir.ts] skills/loadSkillsDir.ts#L270-L401

2)第二步:启动时只加载「索引」,不要加载正文

最简做法:

  • 扫描 skills 目录
  • 解析 frontmatter
  • 只把 name / description / whenToUse / paths / context 放进 registry
  • skill 正文不要此时进 prompt

示意:

async function loadSkillIndex(skillDirs: string[]): Promise<Skill[]> {
const skills: Skill[] = []
for (const dir of skillDirs) {
    for (const skillFile of await listSkillFiles(dir)) {
      const raw = await readFile(skillFile, 'utf8')
      const { frontmatter, content } = parseFrontmatter(raw)
      skills.push({
        name: basename(dirname(skillFile)),
        description: String(frontmatter.description ?? ''),
        whenToUse: frontmatter.when_to_use ? String(frontmatter.when_to_use) : undefined,
        paths: Array.isArray(frontmatter.paths) ? frontmatter.paths : undefined,
        context: frontmatter.context === 'fork' ? 'fork' : 'inline',
        contentLoader: async () => content,
      })
    }
  }
return skills
}

这个阶段要学 Claude Code 的不是目录细节,而是索引和正文分离

3)第三步:做一个「未发送 skill 集合」

这是渐进式披露的核心。

维护一个 session 级状态:

type SkillDisclosureState = {
  sentSkillNames: Set<string>
}

每轮只发送新的:

function getNewSkillListings(skills: Skill[], sent: Set<string>): Skill[] {
  const fresh = skills.filter(s => !sent.has(s.name))
  for (const s of fresh) sent.add(s.name)
  return fresh
}

然后把它格式化成短列表,而不是全文:

function formatSkillListing(skills: Skill[]): string {
  return skills.map(s => `- ${s.name}${s.description}`).join('\n')
}

这对应 Claude Code 的 sentSkillNames + skill_listing attachment 方案。

4)第四步:把文件操作接成动态发现触发器

如果你也想要「技能跟着目录出现」,最小版本就是:

  • 用户或模型读/写/改文件时
  • 从文件父目录往上走到 cwd
  • 看有没有 .agent/skills 或 .claude/skills
  • 找到新目录就加载 skill index

示意:

async function discoverSkillDirsForFile(filePath: string, cwd: string): Promise<string[]> {
const dirs: string[] = []
let current = dirname(filePath)
while (current.startsWith(cwd + sep)) {
    const candidate = join(current, '.agent''skills')
    if (await exists(candidate)) dirs.push(candidate)
    const parent = dirname(current)
    if (parent === current) break
    current = parent
  }
return dirs
}

Claude Code 的现成参考是 [discoverSkillDirsForPaths] skills/loadSkillsDir.ts#L861-L915。

5)第五步:做条件激活,而不是启动时全暴露

如果 skill 定义里有 paths,就不要一开始暴露。
等碰到匹配文件时再激活:

function activatePathScopedSkills(
  pending: Skill[],
  touchedFiles: string[],
): { active: Skill[]; remaining: Skill[] } {
const active: Skill[] = []
const remaining: Skill[] = []
for (const skill of pending) {
    if (!skill.paths || skill.paths.length === 0) {
      active.push(skill)
      continue
    }
    const matched = touchedFiles.some(file => matchAny(file, skill.paths!))
    if (matched) active.push(skill)
    else remaining.push(skill)
  }
return { active, remaining }
}

这就是 Claude Code conditionalSkills -> activateConditionalSkillsForPaths() 的最小复刻。


6)第六步:调用 skill 时才真正加载正文

不要提前把 skill 正文塞到 prompt。
调用时再做:

async function invokeSkill(
  skill: Skill,
  args: string,
  ctx: AgentContext,
): Promise<SkillInvocationResult> {
const prompt = await skill.contentLoader(args, ctx)

if (skill.context === 'fork') {
    const result = await runSubAgent({
      prompt,
      allowedTools: skill.allowedTools,
      model: skill.model,
      effort: skill.effort,
    })
    return { mode: 'fork', result }
  }

return {
    mode: 'inline',
    newMessages: [
      { role: 'user', content: `[SKILL:${skill.name}]` },
      { role: 'user', content: prompt, meta: true },
    ],
    allowedTools: skill.allowedTools,
    model: skill.model,
    effort: skill.effort,
  }
}

这就是 Claude Code SkillTool.call() 的最小骨架。[SkillTool.ts] tools/SkillTool/SkillTool.ts#L581-L863

7)第七步:结果返回必须分 inline 和 fork

直接照 Claude Code 的语义分两种:

inline

  • 返回一个轻 tool_result:Launching skill: xxx
  • 真正内容通过 newMessages 回到主对话继续推理

fork

  • 返回最终结果摘要
  • 子代理对话不污染主上下文

示意:

type SkillInvocationResult =
  | {
      mode: 'inline'
      newMessages: Message[]
      allowedTools?: string[]
      model?: string
      effort?: string
    }
  | {
      mode: 'fork'
      result: string
    }

这一步是很多新 Agent 最容易偷懒的地方。
要么所有 skill 都 inline,主上下文爆炸;要么所有 skill 都 fork,失去细粒度引导。

九、小结

「skills 的渐进式披露」其实就是 Claude Code 在控制 prompt 成本和能力密度时最典型的设计之一。它真正解决的问题不是「怎么找到一个 skill」,而是「怎么在不把上下文撑爆的前提下,让模型知道自己有技能可用」。

它背后的思路:

  • 先给索引
  • 再给局部集合
  • 再给真实正文
  • 最后才给执行结果

这是一个很像搜索引擎的设计:摘要、点击、展开、消费,而不是把整本书扔给你。

以上。