三者本来就是同一件事的三个刻度:一段可复用的工作,区别只在多少写进了代码。 工具是一步就完的最小形态,技能是只给指导、流程仍由模型走的最松形态,agentic function是流程写死、只留决策点的最紧形态。 统一叫agentic program之后,它们是同一个概念在一条连续谱上的三个位置,不是三个并列的东西。 这一页把这条谱画出来,给出「该写哪一种」的判据,并核实统一概念之后实现要不要跟着统一。
先把谱画出来。轴是「这段工作的步骤表落在哪里,有没有约束力」。同一件工作三种写法都能做,差别在于运行之前有多少已经定了。
@function共46个(32个@function(...)装饰器写法,
14个function(...)(fn)模块级调用写法,两种写法进的是同一个注册表);
@agentic_function共4个(extract_pdf_figures、extract_pdf_tables、interaction_demo、goal);
技能openprogram/skills_bundled/下3份、仓库根skills/下4份,加上用户目录和远端缓存装的,
全部经skills/loader.py进系统提示(见第4问)。
三个刻度不是仅有的三个位置。骨架里放一个decision.make是硬里嵌软;技能第三步写「调survey_references」是软里嵌硬。所以谱是连续的。
这是核实之后最反直觉的一条,也是后面「装饰器要不要合并」的前提。谱上的位置由你写的代码决定,装饰器管的是另一个正交的维度。
mixture_of_agents(openprogram/functions/tools/mixture_of_agents/mixture_of_agents.py:290)用asyncio.gather并行发N个模型,
用MIN_SUCCESSFUL_REFERENCES做闸门,再调一次聚合模型。步骤、扇出、闸门全在代码里,站在谱的最右端,
但它经complete_simple直连provider,不经runtime.exec,所以一个ModelCall节点都不落。
结论:装饰器选的是「内部要不要记账」,和「多少写进代码」是两个维度。
落差不在引擎。引擎里做决策的那几个入口,一次都没在生产代码里被调用过。
四个的骨架高度一致:代码算出一个固定的集合,循环里每项调一次模型,模型的回答只作为数据流回代码,从不决定下一步走哪儿。
openprogram/functions/agentics/extract_pdf_figures/__init__.py:98
整份文件里唯一需要判断的是看图给框。翻页、映射坐标、裁剪、写盘全是代码。模型答错一页不影响其余页。
openprogram/functions/agentics/goal/__init__.py:312
只有一个判断,判断本身派一个子agent去做(带只读工具),回来解析成定死的JSON。所有记账、重试计数、预算、停机规则都在goal.py里,在模型外面。
extract_pdf_tables(121行)和interaction_demo(119行)是同一形状的更小版本。
四个里没有一个用decision.make、没有一个用exec(choices=)、没有一个用exec(tools=)。
四个都取as_tool=True和expose="io"的默认值,全部对模型可见。
结论:我们已经证明了「把确定性外壳写进代码」这件事成立,还没开始做「流程里留决策点」。
这一层曾经有两套加载器,读的目录不一样,去重规则相反,喂给模型的格式也不一样。现在只剩一套:openprogram/skills/loader.py。agentic_programming/skills.py已删除。
MAX_LISTING_DESC_CHARS,抄claude-code的数:清单只管发现,正文由skill工具加载)。
条数不截。之前chat那条路截到20条,装了20份以上技能的用户后面那些模型根本看不见,而这正是要修的毛病。
要让清单短,走skills.disabled(设置页、agent档案、Skills页开关都写这一个字段),不靠渲染器悄悄砍。
代价看得见:只有3份自带技能时512 token;装满26份(自带加项目加19份远端拉的)是3055 token。这段在L0,是整段系统提示里最容易命中prefix cache的一段。
还没合的一处:skills/agentic-programming/SKILL.md和skills_bundled/agentic-programming/SKILL.md是两份已经漂开的近似副本,
project来源盖过bundled,所以模型看到的是前者。
skills_bundled/distill/SKILL.md的§3明确给了两条出路:需要判断的写SKILL.md,机械的写@agentic_function。
但整份文档只有§5「Write the SKILL.md」写完整了,函数那条只留了一句「去加载agentic-programming技能」就转走。
结果是可预期的:现有4个agentic function没有一个是distill产出的,而每次会话结束时最省力的选择永远是写散文。
把一次性劳动固化成代码,缺的不是能力,是把「写函数」这条路写得和「写散文」一样省力。
八家全查了。两个问题分开看:一是有没有「流程固化成代码」这一层,二是有没有把工具和技能统一起来。第一个问题只有两家做了,第二个问题有两家做了,而且方向相反。
| 仓库 | 固化层叫什么 | 决策入口 | 失败恢复 | 并行扇出 | 最值得抄的 |
|---|---|---|---|---|---|
| openclaw | Lobster(工作流shell) Task Flow(编排状态机) |
返回值里有needs_input加JSON schema加resumeToken;approval: required停下等布尔批准;步骤里调llm-task --action json拿枚举,下游用写死的condition:分支 |
SQLite flow_runs表带revision列做冲突安全的续跑,WAL检查点,取消意图跨重启保留 |
TaskFlow把多个子任务挂到一个flow上,取消级联到活着的子任务。没有内建的「等N个再收集」原语 | 把决策点定义成一份返回值契约,而不是一个函数签名 |
| codex-cli | SessionTask traitkind() / run() / abort() |
review.rs:固定提示词加强制AskForApproval::Never加一次性子线程,回来尽力解析JSON,解析不出就退回纯文本。管线里有final_output_json_schema参数但review自己没用 |
rollout JSONL加--last恢复,另有SQL状态库;冷rollout压缩是独立后台worker,恢复路径不被它阻塞 |
spawn_agent / wait_agent / interrupt_agent 是一等工具,父子拓扑持久化在agent-graph-store | 一个trait当统一注册点:新固化一个流程就自动获得生命周期、取消、遥测 |
| hermes-agent | 按插件各写的pipeline,没有通用抽象 | 没有。mixture_of_agents只在固定阶段调模型;delegate_task由调用方模型决定派不派,派出去之后全是代码 |
JSON store原子写,每步落盘,retry_scheduled状态支持断点续;会话层是SQLite加WAL加/resume |
ThreadPoolExecutor批量派子agent,并发上限/子超时/派生深度三个参数,可中断可暂停,子agent工具集被剥 | cron的no_agent模式:同一个自动化面上留一条完全不过模型的廉价路径 |
| pi-mono | 没有。只有写死的runLoop加AgentLoopConfig钩子 |
beforeToolCall / shouldStopAfterTurn / prepareNextTurn三个钩子,加provider级toolChoice: {type:"function"}强制发一次指定调用 |
JSONL事件日志加resume/fork;退避重试默认3次;durable-harness.md只是设计稿,类型名在源码里零命中 | 循环原生并行tool call;子agent编排在example扩展里,用有界并发worker池而不是裸Promise.all |
mapWithConcurrencyLimit;chain模式用{previous}做步间传递 |
| opencode | 没有 | plan_exit一个写死的Yes/No闸门,只管一次模式切换,不是通用机制 |
git影子仓自动快照,按消息revert/unrevert重放补丁。是文件状态回退,不是步骤检查点 |
Task工具一次派一个子会话,后台模式还锁在实验开关后面。要并行得模型自己发多次 | 把「加载技能」做成一个内建工具(见下) |
| claude-code-leaked | 没有。workflows只是CLAUDE_CONFIG_DIRECTORIES里的一个保留名,唯一消费者是@补全 |
弱。EnterPlanMode硬闸住编辑但「计划」是自由文本;TaskList是模型自己改的共享状态,不是代码骨架 | 只有会话级resume。compact是上下文截断,不是流程检查点(因为没有流程可检查) | 靠提示词让模型「在一条消息里发多个Task调用」,团队工具持久化但没有拓扑图 | 技能和斜杠命令进同一份清单,清单有token预算(见下) |
| pi-ai | 没有(是个SDK) | 只有原语:typebox校验的Tool加toolChoice强制。没有generateObject这类自由结构化输出 |
没有 | 没有(在pi-mono那层) | toolChoice: {type:"function", name}当「停下来强制一次定型决策」的原语 |
| weclaw | 没有 | 没有 | 没有自己的。会话续跑整个甩给被包的CLI agent,映射只在内存里,重启即丢 | goroutine广播给N个agent收回复,无取消、无部分失败策略 | 反面价值:在传输层刻意不建这一层,整个组件因此小到一个接口 |
这一节是这次新查的。问题是:别人有没有把工具和技能收成一个概念。答案是有两家,一家往左统一(全变成markdown),一家往右统一(技能进工具表)。
context: 'inline' | 'fork'(src/skills/loadSkillsDir.ts:311),
同一份定义用一个开关切两种执行方式,定义不变,执行方式变。
@agentic_function就是那个统一注册点(DAG节点、expose、注册成工具、重试、超时预算都自动获得),
返回值就是那份契约。缺的是有人去用,以及缺一样东西:断点续跑。
统一概念之后这是第一个要回答的问题。三问,每问都能机械地判,第一个命中就停。第三问是「值不值」,不是「是什么」。
decision.make。
一个到处是decision.make的程序,只是把模型的自由度换成了一组固定选项,没有真的收进代码。
闭集分支(下一步走哪条路)用decision.make,应该少见;开放产出(这一步交出什么内容)用runtime.exec加输出契约加代码侧结构检查,那才是常态。按「出现频率 × 搞错的代价」排。三个都是原语,后面两个组合直接调它们。三个都通过了上面的三问:模型必须中间参与,参与点提前点得出来,漏一步不会被当场发现。
survey_references(question: str, dimensions: list[str], repos: list[str] = None) -> dict
这套流程手写了不止一遍,每遍都要重申「按同一批维度查、给文件路径和行号、没有的如实标没有」。 漏掉最后一条就会拿到编造的结论。派出去的四个agent还因为共用同一个模型覆盖,一起撞上配额上限同时死,然后靠手工重发。
verify(checks: list[Check], max_rounds: int = 2) -> Report
真实发生过:假测试全绿,真跑挖出七个问题。要害不在修,在于检查清单本身该是代码而不是记性, 而且清单里必须能放「真跑一次」这种项,不只是单测。
read_own_code(scope: str, questions: list[str]) -> list[Finding]
犯过的最贵的错:引用了一个没核实的索引,结论直接错。 代价小得离谱的修法是让代码去查引用,而不是让人去记得核实。这一条的「防住的伤害除以行数」是三个里最高的。
| 组合 | 骨架 | 模型只管 | 规模 |
|---|---|---|---|
| design_page(topic, layers, out) | read_own_code查现状 → survey_references查八家 → 计划层和理想层各一次调用 → 用固定样式模板拼HTML → 建站 → checklinks → 登记nav | 四层各自的内容 | 约100行,调用前两个原语 |
| parallel_worktree(tasks) | 一任务一个worktree → 一worktree一个agent → 收 → 按序rebase到origin/main → push → 清理 | 每个worktree里的活 | 约90行。两个agent撞同一个文件这件事在结构上不可能再发生 |
| 要加什么 | 为什么 | 规模 | 不加什么 |
|---|---|---|---|
| once(key, fn) | 断点续跑。读现成的DAG节点,不新增写入方、不新增文件格式 | 约30行 | 不加SQLite flow_runs表(openclaw那套对我们是重复造DAG) |
| exec_json(prompt, schema) | 开放产出的输出契约。现在三处各手写一遍JSON解析,response_format=零使用 | 约25行,包一层现成的exec加json_parsing.parse_json | 不加新的schema DSL,decision.py的_normalize_field已经能表达嵌套结构 |
| fan_out(prompts, fallback_models) | 并行步骤的固定写法,含模型降级。四个agent同时死在配额上就是缺这个 | 约40行,包agent()加task_output() | 不加调度器、不加并发池。agent.max_spawn_fanout已经在限上界 |
| 一个 schema 生成器 | 合并_build_parameters_schema和_build_agentic_tool_spec。两份已经漂了,agentic那份认不出X | None、不放宽null、不写additionalProperties:false(见第3问) | 删约60行近似重复,改两个调用点 | 不合并装饰器。_is_agentic扛着九处行为,压进一个参数只会更难发现 |
@agentic_function当统一注册点(DAG、expose、注册成工具、重试、超时预算自动获得),
agent()/task_output()/task_stop()当并行执行底座(级联取消、派生深度、扇出、消息数四个预算已经在配置里),
decision.make当闭集分支。不新增装饰器,不新增名词进代码。
技能那一层同理:skill工具复用skills/tool.py已有的解析和调用记录,没有新名词。
1. 连续谱的轴是什么?谱上只有三个刻度吗?
轴是这段工作的步骤表落在哪里,以及它有没有约束力:工具那一端步骤表根本不存在(只覆盖一步,步与步之间的事全在对话里现决定);技能是步骤表写成散文(写下来了,模型读了可以不照做);agentic function是步骤表写成Python(解释器执行,不照做不可能)。
两个候选说法里,「多少写进代码」比「控制流握在谁手里」准。控制流那个说法在工具那一端会失真:mixture_of_agents是一个@function工具,体内是「并行扇出N个模型 → 最少成功数闸门 → 一次聚合调用」,控制流完全在代码里。所以控制流不是区分点。
可测量的定义:同一件事跑两遍,有多少东西一定相同。工具几乎没有,技能大致相同、细节会漂,agentic function骨架逐字相同、只有内容不同。这把轴从一个说法变成一个能测的量。
不是只有三个刻度。三个是常见落点。骨架里放一个decision.make是硬里嵌软;技能第三步写「调survey_references」是软里嵌硬。所以谱是连续的,一段工作可以一部分硬一部分软。
还有一条要写进设计:装饰器不决定你在谱上的位置。见第3问。
2. 我要加一个能力,该写哪一种?
三问,第一个命中就停(图见第3节):
Q1 需要模型在中间参与吗?不是问「调不调模型」,是问「要不要停下来看模型答了什么,再决定后面怎么走」。答否就是工具,哪怕体内调了模型。mixture_of_agents体内调N+1次模型,外面看是一进一出,所以它是工具,写法没错。
Q2 参与点提前点得出来吗?写代码的时候能不能说出模型要在第几步、一共几次进来。答否就是技能:每次几个、在哪儿,得跑起来看到东西才知道。
Q3 漏一步会被当场发现吗?这是「值不值」,不是「是什么」。会发现(结果明显不对)就技能够用,别付一份代码的维护成本。不会发现(验证过再交付、引用要核实、八个仓库都要查)才写agentic function。把清单从记性搬进代码的唯一理由,就是漏了不会被发现。
三种各有一条可证伪的验收:工具选对了,参数schema能完整描述输入,description里不需要写「用之前先……」;技能选对了,内容是判断依据不是步骤编号,整份文档里没有「第1步/第2步」;agentic function选对了,你能说出这一次的模型调用次数上界,说不出来说明流程没真的固化。
这个判据能解释现有分布(46 / 7 / 4),不是事后编的:绝大多数能力真的是一进一出,「参与点提前点得出来而且漏了看不出来」本来就是少数。第四种情况是「这件事只做一次」,那就直接干,什么都别写。
3. 两个装饰器要不要合并成一个、用参数区分?
不合并装饰器,合并schema生成器。下面是核实的结果。
已经统一的部分比想象的多。@agentic_function._register_as_tool(function.py:963)调的就是@function用的那个_build_and_register_tool(_runtime.py:1037)。同一个_registry,同一个tools数组,同样六层门禁,20个关键字参数同名同义(name、description、parameters、label、toolset、unsafe_in、check_fn、requires_env、can_use、defer、cache等)。所以注册路径、工具面暴露、调用约定这三样差得很小。
真正差的第一样:参数schema生成器是两份,而且已经漂了。_build_parameters_schema(_runtime.py:426)会读docstring的Args:段、把可选参数放宽成允许null、递归写additionalProperties:false、用get_type_hints解字符串注解、认PEP-604的X | None、认Literal。_build_agentic_tool_spec(function.py:1302)一样都没有:它读input={}的UI元数据,只判typing.Union而PEP-604的origin是types.UnionType,认不出的一律落到{"type":"string"}。
_runtime.py:312-321有一整段注释说明这个union问题曾经打断过生产里的工具调用,那个修复没有搬到agentic这边,现在还是活的。这一处该合,合完删约60行近似重复,顺手修掉一个活问题。
真正差的第二样:_is_agentic不是装饰,是开关。它扛着九处行为:loop_runner.py:108决定渲染成带执行图的运行块还是折叠的工具卡;runtime_attach.py:128决定要不要fork出子进程跑(为了停止键能SIGKILL整个进程组);forced_tool.py:74和webui/routes/chat.py:184是两道硬门,非agentic直接报错或400;tree.py:93决定进不进Functions面板的内建列表。合成一个装饰器就要把这九处压进一个参数,那个参数一翻就同时翻九件事,和两个装饰器是同一回事,只是更难发现。
还有一个名字撞车得先解决。_build_and_register_tool(expose: bool)的expose是「给不给模型看」,@agentic_function(expose="io"|"llm"|"full"|"hidden")的expose是「DAG里露多少」。两个不相干的意思共用一个词,而且agentic那边压根没给注册函数传expose(function.py:963-977),永远取True。
为什么概念统一而实现不统一是可接受的:因为两个装饰器区分的从来不是概念位置。mixture_of_agents用@function写了一个完全固化的多步流程,站在谱的最右端;interaction_demo用@agentic_function写了一个很小的东西。装饰器选的是内部要不要记账:@agentic_function的模型调用走runtime.exec,每次落一个ModelCall节点进DAG,受expose/render_range约束;@function的模型调用(mixture_of_agents用complete_simple直连)不进DAG,只在usage里记一笔call_kind="tool"。这是一个和「多少写进代码」正交的维度,不该合并。
代价对比:合schema生成器约删60行、修一个活问题、改两个调用点,该做;合装饰器要把九处行为压进一个开关、先给expose解歧义、还要在「description取第一段还是取整份docstring」里二选一(4个agentic function的docstring就是提示词,截成第一段会直接改掉它们的行为),不该做。
4. skills_bundled/那个目录怎么办?技能要不要也有个「注册」动作?
技能曾经注册了两套,而且两套互不认识:agentic_programming/skills.py只读front matter的两个字段、扫两个目录、同名先见先赢;skills/loader.py读正文、还读category/allowed_tools/triggers/aliases、扫五个来源、同名后见先赢。三条后果都是实打实的:skills_bundled/不在喂系统提示那条路上,所以distill和self-update模型根本看不见;两个渲染器输出不一样,chat那条路截到20条且不给路径;SkillTool写好了从来没注册。
现在三条都合了。一个加载器:agentic_programming/skills.py已删除,Runtime(skills=...)和skills_index组件都走skills/loader.py。一份清单:format_skills_for_prompt是唯一渲染器,两条路输出同一段<available_skills>,带name、截到250字符的描述首行、绝对路径。一个加载动词:skill工具(functions/tools/skill/)进了注册表,收层级名或不歧义短名,返回正文加基准目录加同目录文件清单,调用记入skills/tool.py的invoke_log.jsonl,Skills页读的就是这份记录。
冲突顺序也定死了:bundled → remote-cache → plugin → user → project,后见先赢。用户自己写的盖过给他装的,装的盖过随程序发的;同是用户写的,项目里那份盖过家目录那份。之前remote-cache排在最后,从ClawHub拉一个同名技能会盖掉用户自己写的,方向反了。
skill工具在DEFERRED_DEFAULT_TOOLS里:清单每轮已经列了每份技能的名字和描述,schema只在模型真要加载时才由tool_search拉进当轮,平时只占catalog一行。read打开清单里的location同样有效,两条路都留着。skills.py:19当初写的取舍(「We intentionally reuse read」)没有作废,只是不再是唯一入口。
技能现在和工具在同一张表里、同一套门禁、同一份清单。让概念成立的不是共用装饰器,是共用注册表和共用清单。
5. 统一之后模型眼里该是一种还是几种?
曾经是三种半,那半种是同一批技能在chat那条路上的第二种写法,无路径、截到20条。它已经删了,现在是三种:①tools数组里的schema(@function加@agentic_function,split_tools_for_dispatch拆的);②deferred目录(defer=True的,系统提示里一行name加description,靠tool_search把schema拉进当轮);③<available_skills>(name加description加绝对路径,靠skill工具或read打开)。
②和③形状完全一样:系统提示里一行name加description,外加一个把正文拉进来的动作。区别只有动作的名字和目录的标签。skill工具本身就在②里,所以两份目录现在至少指向同一套加载语义;把它们并成一份清单是下一步,那是渲染层的事,注册层已经并完了。
终点是两种:能直接调的(tools数组,带schema),和知道名字得先加载的(一份目录,一个加载动词,按类型分派:deferred拉schema,技能拉正文)。
两家参考实现已经这么做了,而且都是把「加载」做成工具。opencode的skill是内建工具之一(packages/opencode/src/tool/registry.ts:210,和read/grep/task并列),入参name,返回<skill_content>加基准目录加<skill_files>,带permission: "skill"走同一套审批;系统提示那段(core/src/skill/guidance.ts:18)直接写「Use the skill tool to load a skill when a task matches its description」。claude-code的SkillTool和ToolSearchTool并排在同一个工具列表里(src/tools.ts:212),而且更进一步:getSkillToolCommands的注释写着「SkillTool shows ALL prompt-based commands that the model can invoke」,技能和斜杠命令进的是同一份清单。
清单要有预算,数可以直接抄claude-code的:SKILL_BUDGET_CONTEXT_PERCENT = 0.01(上下文窗口的1%),MAX_LISTING_DESC_CHARS = 250,注释说明理由「The listing is for discovery only,the Skill tool loads full content on invoke」。我们已经有全部零件,差的只是接成一条。
6. 这次不改代码。真要改名,影响面多大?
分四层,代价差一个数量级。见下表。建议:0和1现在就做,2等新名字用顺了再机械替,3单独当一次提示词改动来评,4不做。
openprogram/functions/agentics/被132处斜杠路径引用(61个文件),牵到
.gitignore:51,57,58和pyproject.toml:201,215,216;kind="agentic_function"(streaming/__init__.py:24)是跨进程流协议里的枚举值。
共170处以上,改了只换来一次全仓大改,目录名和线上枚举本来就不是概念名。
skills/agentic-programming/SKILL.md和skills_bundled/agentic-programming/SKILL.md是两份已经漂开的近似副本(30处和31处),
改名之前先把它们合成一份,否则改名等于把同一个错误抄两遍。
decision.make现在的形态够不够表达「让模型判断哪条值得抄」?
不够,而且方向不对。「哪条值得抄」不是从一组固定选项里挑一项,是产出一个开放列表,而decision.make是闭集选择器。
要分清两种入口:闭集分支(下一步走哪条路)用它,在agentic program里应该少见;开放产出(这一步交出什么内容)用runtime.exec加输出契约加代码侧结构检查,那才是常态。
第二种现在没有便利入口,exec(response_format=)零使用,三个地方各手写一遍JSON解析。补一个exec_json(prompt, schema)就够,约25行,不是新层。
跑了半小时的 program 中间失败了怎么办?session.py管不管?
不管。Session是ask_user在CLI下的文件传递目录:uuid键目录,meta.json加一个answer触发文件,原进程wait_for_answer轮询,run_with_session在finally里cleanup()删目录。跑了半小时崩掉,它什么都不留。
真正落盘的是DAG:create_pending_call_node进入时写status=running、output=None的节点,_update_function_call_exit退出时补上输出和耗时,进git支撑的会话库。
每个已完成步骤的输入和输出已经在磁盘上了,只是没人读回来。最省的补法是once(key, fn),约30行,不新增写入方、不新增文件格式。上界必须说清:只对「输入决定输出」的步骤正确,写文件的步骤是被跳过不是被回放,所以要显式选择加入。
成本可预测是不是主要收益之一?
是,但要说准:固化之后不是每次调用次数固定,是次数有上界且上界写在代码里、看得见。 排在成本前面的收益其实是另外两个:漏步骤变得不可能(清单是代码,不是记性),结论可核实(出处检查是闸门,不是礼貌)。 三次真实事故里,两次是漏步骤和没核实,一次是两个agent撞同一个文件,都不是成本问题。
openprogram/agentic_programming/function.py、runtime.py、decision.py、session.py;
openprogram/functions/_runtime.py、openprogram/functions/agentics/、openprogram/functions/tools/(技能加载动词在tools/skill/);
openprogram/skills/loader.py、openprogram/skills/tool.py;openprogram/context/components.py;
openprogram/skills_bundled/distill/SKILL.md。
相关设计页:函数与工具调用的统一、Agent协作。