Agentic Program:工具、技能、agentic function是同一个概念

三者本来就是同一件事的三个刻度:一段可复用的工作,区别只在多少写进了代码。 工具是一步就完的最小形态,技能是只给指导、流程仍由模型走的最松形态,agentic function是流程写死、只留决策点的最紧形态。 统一叫agentic program之后,它们是同一个概念在一条连续谱上的三个位置,不是三个并列的东西。 这一页把这条谱画出来,给出「该写哪一种」的判据,并核实统一概念之后实现要不要跟着统一。

1 我们现在怎么做的 2 别人怎么做的 3 计划怎么优化 4 理想状态 5 六个设计问题的回答

01我们现在怎么做的

先把谱画出来。轴是「这段工作的步骤表落在哪里,有没有约束力」。同一件工作三种写法都能做,差别在于运行之前有多少已经定了。

代码定死(解释器执行,不照做不可能) 散文写了(模型读了可以不照做) 模型现定(每次跑都可能不一样) 不存在
一条轴:这段工作的步骤表落在哪里 可测量的说法:同一件事跑两遍,有多少东西一定相同。左端几乎没有,右端骨架逐字相同、只有内容不同。 步骤表不存在 步骤表是代码 @function 工具 生产里46个 SKILL.md 技能 仓库里7份,进提示的4份 @agentic_function agentic函数 生产里4个 这一格由谁定 有哪些步骤 对话里现定 散文里列了 Python里定死 步骤的顺序 对话里现定 散文里建议 Python里定死 并行扇出宽度 对话里现定 散文里建议 Python里定死 验收闸门 不存在 散文里提醒 代码检查,不过不放行 产出的形状 不约束,返回什么都行 不存在 代码定死,不合就重问 产出的内容 模型 模型 模型 最后一行三格全是模型:三种写法从来没有一种把「内容」收进代码,它们抢的全是上面五行。这条谱只关于上面五行。
存量按生产代码统计(不含测试):@function共46个(32个@function(...)装饰器写法, 14个function(...)(fn)模块级调用写法,两种写法进的是同一个注册表); @agentic_function共4个(extract_pdf_figuresextract_pdf_tablesinteraction_demogoal); 技能openprogram/skills_bundled/下3份、仓库根skills/下4份,加上用户目录和远端缓存装的, 全部经skills/loader.py进系统提示(见第4问)。 三个刻度不是仅有的三个位置。骨架里放一个decision.make是硬里嵌软;技能第三步写「调survey_references」是软里嵌硬。所以谱是连续的。

装饰器不决定你在谱上的位置

这是核实之后最反直觉的一条,也是后面「装饰器要不要合并」的前提。谱上的位置由你写的代码决定,装饰器管的是另一个正交的维度。

横轴是概念位置,纵轴是装饰器管的事:内部的模型调用记不记账 步骤表不存在 步骤表是代码 进DAG 可被后续 调用看见 不进DAG 对外是黑盒 @agentic_function 模型调用走 runtime.exec,每次落一个 ModelCall 节点,受 expose / render_range 约束 interaction_demo extract_pdf_figures goal @function 体内调模型也不进DAG,只在 usage 里记一笔 call_kind="tool" read grep mixture_of_agents 技能:没有装饰器,也没有帐 同一个 @function 覆盖了整条横轴 mixture_of_agents 是完全固化的多步流程:并行扇出N个模型, 最少成功数闸门,再一次聚合调用。它写成了工具。
mixture_of_agentsopenprogram/functions/tools/mixture_of_agents/mixture_of_agents.py:290)用asyncio.gather并行发N个模型, 用MIN_SUCCESSFUL_REFERENCES做闸门,再调一次聚合模型。步骤、扇出、闸门全在代码里,站在谱的最右端, 但它经complete_simple直连provider,不经runtime.exec,所以一个ModelCall节点都不落。 结论:装饰器选的是「内部要不要记账」,和「多少写进代码」是两个维度。

引擎造好了什么,生产里真正用到了什么

落差不在引擎。引擎里做决策的那几个入口,一次都没在生产代码里被调用过。

引擎里有,生产里在用 runtime.exec(content=) 一次模型调用,落一个 ModelCall节点 3 处 DAG节点(入口/出口) 进入写status=running, 退出补output与耗时 4 处,全自动 expose / render_range 裁剪外面看得见什么、 里面看得见什么 4 处,取默认 重试 + timeout_s 退避重试、墙钟预算、 on_retry观测钩子 全局默认生效 注册成工具 和@function进同一个 registry,模型可直接调 4 处 引擎里有,生产里 0 次 decision.make(...) 从闭集里选下一步,选中 的函数直接跑并返回 0 处(只有测试) exec(choices=...) 先干活再收尾选一个, 同一套选项与解析 0 处 exec(tools=, max_iter=) 步内工具循环。goal绕开它, 改用run_agent_turn派子agent 0 处 exec(response_format=) provider原生结构化输出 0 处 于是每处各写一遍 _parse_json_array、 parse_json、手抠花括号 3 套解析代码
这张图是整份设计的起点:决策原语建好了,但没有一个生产调用点。 不是因为它不好用,是因为现有4个agentic function根本不需要分支,它们的形状都是「确定性循环里放一个模型算子」。

现有4个agentic function长什么样

四个的骨架高度一致:代码算出一个固定的集合,循环里每项调一次模型,模型的回答只作为数据流回代码,从不决定下一步走哪儿。

代码步骤(确定) 模型步骤 闸门 / 校验 并行扇出

extract_pdf_figures

openprogram/functions/agentics/extract_pdf_figures/__init__.py:98

整份文件里唯一需要判断的是看图给框。翻页、映射坐标、裁剪、写盘全是代码。模型答错一页不影响其余页。

CODE
_parse_pages
页码范围解析成 lo, hi
CODE 循环
for page in lo..hi
渲染成预览PNG
LLM 每页一次
runtime.exec(图+提示)
回图框坐标JSON
CODE
_parse_json_array
手写解析,容错靠抠花括号
CODE
坐标映射 + 裁剪 + 写盘
回 [{page,label,caption,path}]
模型调用次数 = 页数217行

goal

openprogram/functions/agentics/goal/__init__.py:312

只有一个判断,判断本身派一个子agent去做(带只读工具),回来解析成定死的JSON。所有记账、重试计数、预算、停机规则都在goal.py里,在模型外面。

CODE
render_session_view
压缩上下文视图(摘要+尾巴)
CODE
_decision_prompt
提示就是函数docstring
LLM 一次
run_agent_turn(tools_override)
只读工具,自己决定查不查
GATE
_parse_decision
met必须是bool,checklist长度必须对齐,否则算失败一次
模型调用次数 = 1418行
extract_pdf_tables(121行)和interaction_demo(119行)是同一形状的更小版本。 四个里没有一个用decision.make、没有一个用exec(choices=)、没有一个用exec(tools=)。 四个都取as_tool=Trueexpose="io"的默认值,全部对模型可见。 结论:我们已经证明了「把确定性外壳写进代码」这件事成立,还没开始做「流程里留决策点」。

技能这一层:一个注册表,一份清单,一个加载动词

这一层曾经有两套加载器,读的目录不一样,去重规则相反,喂给模型的格式也不一样。现在只剩一套:openprogram/skills/loader.pyagentic_programming/skills.py已删除。

一个加载器:skills/loader.py 五个来源,按优先级从低到高排: bundled → remote-cache → plugin → user → project 同名后见先赢。用户自己写的盖过给他装的,装的盖过随程序发的。 名字取自目录相对来源根的路径,所以有层级:anthropic-skills/docx。 读正文,也读 category / allowed_tools / triggers / aliases。 skills_bundled/ 是第一来源,distill 和 self-update 现在在清单里。 一个加载动词:skill 工具 functions/tools/skill/ 入参 name,收整条层级名或任何不歧义的短名(docx)。 返回正文加基准目录加同目录文件清单。 在 DEFERRED_DEFAULT_TOOLS 里:每轮只占 catalog 一行, 模型真要加载技能时才用 tool_search 拉 schema。 读清单里的 location 用 read 打开,同样有效,两条路都留着。 一个渲染器:format_skills_for_prompt runtime.py(exec 那条路) Runtime(skills=True) 读五个来源 context/components.py(chat 那条路) skills_index 组件,减掉 skills.disabled <available_skills><skill><name></name><description></description><location>/abs/path/SKILL.md</location></skill>
描述取首行,截到250字符(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.mdskills_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产出的,而每次会话结束时最省力的选择永远是写散文。 把一次性劳动固化成代码,缺的不是能力,是把「写函数」这条路写得和「写散文」一样省力。

02别人怎么做的

八家全查了。两个问题分开看:一是有没有「流程固化成代码」这一层,二是有没有把工具和技能统一起来。第一个问题只有两家做了,第二个问题有两家做了,而且方向相反。

步骤表不存在 步骤表是代码 八家参考实现的落点 没有这一层(5家) claude-code-leaked 只有一个叫workflows的保留目录名,没有加载器 opencode command/skill/agent全是markdown模板展开成提示词 pi-ai / pi-mono 只有写死的agent循环加钩子; durable-harness.md是设计稿,源码里零命中 weclaw 有实例,没有通用层(1家) hermes-agent 按插件各写各的pipeline:TeamsMeetingPipeline 是真状态机,每步落盘,失败进retry_scheduled; mixture_of_agents是写死的「N路并行再聚合」两段式 边界说得最直白: 「脚本干机械活(抓取、比对、计算), agent干推理」,cron还有个no_agent模式完全不过模型 真有这一层(2家) openclaw 两个东西:Lobster(typed JSON管线,外部npm包) 加 Task Flow(SQLite持久化状态机) codex-cli SessionTask trait,注释原文写着 「encapsulate a specific Codex workflow」 regular / review / compact / user_shell 各是一个Rust结构体
仓库固化层叫什么决策入口失败恢复并行扇出最值得抄的
openclaw Lobster(工作流shell)
Task Flow(编排状态机)
返回值里有needs_input加JSON schema加resumeTokenapproval: required停下等布尔批准;步骤里调llm-task --action json拿枚举,下游用写死的condition:分支 SQLite flow_runs表带revision列做冲突安全的续跑,WAL检查点,取消意图跨重启保留 TaskFlow把多个子任务挂到一个flow上,取消级联到活着的子任务。没有内建的「等N个再收集」原语 把决策点定义成一份返回值契约,而不是一个函数签名
codex-cli SessionTask trait
kind() / 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 没有。只有写死的runLoopAgentLoopConfig钩子 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),一家往右统一(技能进工具表)。

opencode:技能是一个内建工具 packages/opencode/src/tool/registry.ts:210 builtin: [ shell, read, glob, grep, edit, write, task, fetch, todo, search, skill, patch, question ] skill 工具和 read / grep / task 完全平级 入参 name,注释写着「the name of the skill from available_skills」 返回 <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:一份清单装下技能和斜杠命令 src/tools.ts:212,SkillTool 和 ToolSearchTool 并排 两个加载动词并存:ToolSearchTool 拉 deferred 工具的schema, SkillTool 拉技能正文。发现清单是同一种形状。 比opencode更进一步:清单里不只有技能 getSkillToolCommands 的注释:「SkillTool shows ALL prompt-based commands that the model can invoke」 技能和斜杠命令进的是同一份清单 清单本身有预算,理由写在注释里 SKILL_BUDGET_CONTEXT_PERCENT = 0.01(上下文的1%), MAX_LISTING_DESC_CHARS = 250:清单只管发现,正文调用时才加载
往左统一的那家也在这张表上:opencode把command、skill、agent全做成markdown模板展开成提示词, 这是把三样都降到最松的形态。但它同时把「加载技能」提升成了工具,所以它两个方向都做了一半。 claude-code的取法是我们该抄的:保留三种形态,统一的是发现清单和加载动作。 另有一条相关的:claude-code的技能front matter里有context: 'inline' | 'fork'src/skills/loadSkillsDir.ts:311), 同一份定义用一个开关切两种执行方式,定义不变,执行方式变。

两个真做成固化层的,它们各自的形状

openclaw:返回值契约加持久状态机 流程的每一步返回一个带status的对象,status决定运行时怎么办 status: ok 继续下一步 needs_input 带 schema 加 resumeToken 挂起,等一份定型的输入 approval 停下等一个布尔 Task Flow:SQLite flow_runs(currentStep / stateJson / waitJson / revision) 崩了从currentStep接着跑,revision防并发覆盖,取消意图跨重启保留 它自己写的非目标,几乎就是我们要划的那条线: "It does not own branching or business logic. Keep conditionals above the runtime." codex-cli:一个trait当统一注册点 每个固化流程实现同一个trait,自动获得生命周期、取消、遥测 trait SessionTask kind() / run() / abort() regular · review compact · user_shell 四个Rust结构体,跑在后台Tokio任务上 review.rs 的完整形状(最值得照抄的一个) 1. 固定系统提示词 REVIEW_PROMPT,不给模型改的余地 2. 强制 AskForApproval::Never,子线程不许问人 3. 一次性子线程跑完就回来(run_codex_thread_one_shot) 4. 尽力解析成定型事件,解析不出退回纯文本,不炸
两家的取法不同但结论一致:固化层要么给出一份「每步返回什么」的契约(openclaw),要么给出一个「每个流程实现什么」的统一注册点(codex)。 我们两样都已经有了:@agentic_function就是那个统一注册点(DAG节点、expose、注册成工具、重试、超时预算都自动获得), 返回值就是那份契约。缺的是有人去用,以及缺一样东西:断点续跑。

03我们计划怎么优化

先解决最实际的问题:我要加一个能力,该写哪一种

统一概念之后这是第一个要回答的问题。三问,每问都能机械地判,第一个命中就停。第三问是「值不值」,不是「是什么」。

三问决策树,右边一列是「选对了怎么验证」 Q1 需要模型在中间参与吗 不是「调不调模型」,是「要不要停下来看模型 答了什么,再决定后面怎么走」 @function 工具。哪怕体内调了模型,只要 外面看是一进一出,就是工具 验证:参数schema能完整描述输入 description里不需要写「用之前先……」。要写,说明它 不是一步,是被硬压成一步的多步流程 Q2 参与点提前点得出来吗 写代码的时候,能不能说出模型要在第几步、 一共几次进来 SKILL.md 技能。每次几个、在哪儿,得跑 起来看到东西才知道 验证:内容是判断依据,不是步骤编号 整份文档里出现「第1步、第2步」,说明步骤表其实写 得出来,只是写在了不强制的地方 Q3 漏一步会被当场发现吗 这是「值不值」的问题, 不是「是什么」的问题 技能就够,别写代码 结果明显不对就会被看见, 不值得为它付一份代码的维护 不会 @agentic_function 「验证过再交付」「引用要核实」「八个仓库都要查」。把清单从记性搬进代码的唯一 理由,就是漏了不会被发现 验证:你能说出这一次的模型调用次数上界 说不出来,说明流程没真的固化,只是把一段散文换了 个地方放
这个判据能解释现有的分布,不是事后编的:46个工具、7份技能、4个agentic function。 绝大多数能力真的是一进一出(Q1答否);「参与点提前点得出来,而且漏了看不出来」本来就是少数。 三问都不适用的第四种情况:这件事只做一次。那就直接干,什么都别写。

线划在哪:代码持有什么,模型持有什么

代码持有(收进来) 步骤顺序 哪几步、什么次序、能不能跳 扇出宽度与分区 派几个、每个看哪一份、怎么合 验收闸门 下一步之前必须过什么检查 产出契约 每步必须交出什么形状,缺了就重问 退出条件与预算 模型持有(留出去) 对开放内容的判断 是不是同一个东西、哪条值得抄 从非结构输入里抽结论 读代码,产出一条带出处的断言 措辞 同一个意思用哪句话说 失败之后改什么 检查没过,看日志决定动哪里 步内取舍:这一步开哪些文件 一步 = 一份定死的输入契约    + 一次模型调用    + 一份定死的输出契约 agentic program从不问模型「接下来干什么」, 凡是它自己推得出来的都不问。 只在答案真的推不出来的地方开口。
推论一条,很容易搞反:agentic program里大多数步骤不该是decision.make 一个到处是decision.make的程序,只是把模型的自由度换成了一组固定选项,没有真的收进代码。 闭集分支(下一步走哪条路)用decision.make,应该少见;开放产出(这一步交出什么内容)用runtime.exec加输出契约加代码侧结构检查,那才是常态。

模型眼里该是几种东西:今天三种,应该是两种

今天:三种 ① tools 数组:带完整 schema,直接可调 46个 @function 加 4个 @agentic_function(split_tools_for_dispatch 拆的) ② deferred 目录:系统提示里一行 name + description defer=True 的,靠 tool_search 把 schema 拉进当轮 ③ <available_skills>:name + description + 绝对路径 靠 skill 工具或 read 打开正文。chat 和 exec 两条路同一段文本 曾经有个 ③′:同一批技能的项目符号列表,无路径 chat 那条路自己拼的,已删。两个渲染器合成一个 ② 和 ③ 形状完全一样: 系统提示里一行 name + description,加一个把正文拉进来的动作。 区别只有动作的名字(tool_search 还是 read)和目录的标签。 提议:两种 ① 能直接调的 tools 数组,带 schema。不变。 agentic program 的三种形态里,工具和 agentic function 已经 在同一个数组里了(同一个 _build_and_register_tool) ② 知道名字得先加载的:一份目录,一个加载动词 deferred 工具和技能进同一份清单,每行 name + description + 类型。 加载动作按类型分派:deferred 拉 schema,技能拉正文。 已有零件:skill 工具(已注册,且本身就是 deferred)、 deferred 目录(_runtime.py:1212)、加载动词先例(tool_search)。 差的只剩把两份目录渲染成一份。 清单要有预算,抄 claude-code 的数: 上下文窗口的 1%,每条描述截到 250 字符。 清单只管发现,正文调用的时候才加载。

建议优先固化的三个原语

按「出现频率 × 搞错的代价」排。三个都是原语,后面两个组合直接调它们。三个都通过了上面的三问:模型必须中间参与,参与点提前点得出来,漏一步不会被当场发现。

1. survey_references 对标参考实现

survey_references(question: str, dimensions: list[str], repos: list[str] = None) -> dict

这套流程手写了不止一遍,每遍都要重申「按同一批维度查、给文件路径和行号、没有的如实标没有」。 漏掉最后一条就会拿到编造的结论。派出去的四个agent还因为共用同一个模型覆盖,一起撞上配额上限同时死,然后靠手工重发。

CODE
discover(repos)
references/下的目录就是仓库清单,不用人列
CODE
build_prompt(repo, dims)
维度逐字带上,「没有就标没有」写在模板里,不靠记性
FAN-OUT
agent(run_in_background=True)
一仓一个,模型降级清单写在代码里
CODE
task_output(t)
逐个收,失败的按降级清单重派
GATE
完整性检查
仓库×维度的格子必须填满,空格子重问那一仓那一维
LLM 一次
merge(rows)
判断哪些是可比的、哪些值得抄
顶层模型调用上界 = 仓库数 + 空格子数 + 1约120行

2. verify 实施纪律的闸门

verify(checks: list[Check], max_rounds: int = 2) -> Report

真实发生过:假测试全绿,真跑挖出七个问题。要害不在修,在于检查清单本身该是代码而不是记性, 而且清单里必须能放「真跑一次」这种项,不只是单测。

CODE
for check in checks
单测 / checklinks / checklang / 真跑一次,各是一条
CODE
run + 收 (name, ok, tail)
子进程跑,不过模型
GATE
全绿?
是就返回,一次模型调用都不花
LLM 每轮一次
读失败输出,决定改哪里
这是唯一需要判断的地方
CODE
轮数用完就报告不修
上界写在参数里
模型调用上界 = max_rounds(全绿时为0)约60行

3. read_own_code 核实而不是推断

read_own_code(scope: str, questions: list[str]) -> list[Finding]

犯过的最贵的错:引用了一个没核实的索引,结论直接错。 代价小得离谱的修法是让代码去查引用,而不是让人去记得核实。这一条的「防住的伤害除以行数」是三个里最高的。

CODE
locate(scope)
有.codegraph就用,没有就grep
LLM 每问一次
回答 + 必须给 file:line
出处是输出契约的一部分,不是礼貌
GATE
引用真伪检查
路径必须存在,行号必须在文件长度内,该行必须含断言里的标识符
CODE
不过的重问一次
还不过就标成「未核实」,不许静默通过
模型调用上界 = 2 × 问题数约80行

再往上两个组合(等原语稳了再做)

组合骨架模型只管规模
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撞同一个文件这件事在结构上不可能再发生

断点续跑:数据早就在盘上了,只差一次读

先澄清一个误解: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 同一个id补上output与耗时 git支撑的会话库 每个已完成步骤的输入和输出 已经在磁盘上,只是没人读回来 once(key, fn) 跑之前查同名同参且已完成 的节点,命中就回放 上界要说清楚:这只对「输入决定输出」的步骤正确。 写文件的步骤不是被回放,是被跳过。文件还在就对,文件没了就错。所以必须显式选择加入,不能默认开。约30行,不是新装饰器。

需要新增的机制一共只有四样

要加什么为什么规模不加什么
once(key, fn)断点续跑。读现成的DAG节点,不新增写入方、不新增文件格式约30行不加SQLite flow_runs表(openclaw那套对我们是重复造DAG)
exec_json(prompt, schema)开放产出的输出契约。现在三处各手写一遍JSON解析,response_format=零使用约25行,包一层现成的execjson_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已有的解析和调用记录,没有新名词。

04理想状态

做到位之后,写代码和用这套系统是什么样 一个概念,一份清单 「这个仓库有哪些 agentic program」有一个答案, 不管它是一步的、一份散文 还是一段固化流程。 模型和人看的是同一份 干过一次就往右移一格 会话结束时 distill 的产出 是「把这份技能的第2到4步 收进代码」,不是又一份散文。 同一个主题第二次做是改它。 技能退成路由:什么情况跑哪个 顶层模型的活变小 从「把这一整件事想清楚再做」 缩成「挑哪个 program、填什么 参数」。这一步本身就是一次 decision.make,选项是那份清单。 决策原语第一次有真实用途 成本是代码里的一个数 不是「次数固定」,是 上界写在代码里、看得见。 八个仓对标 = 8次派发 加1次合并,不管谁来跑。 跑两遍花的钱是同一个量级 现在做不到的,如实列 步骤级续跑不存在 DAG里数据齐了,缺一个读回来的 once()。 而且只对纯步骤正确,写文件的步骤 是跳过不是回放,必须显式选择加入。 单个 program 的成本没有账 usage/ledger.py 记的是用量事件, 没有任何东西把它和某个函数调用节点 连起来。「这次跑花了多少」现在答不了。 决策原语的人机工程未经检验 decision.make 生产调用数为0, 它在真实规模下好不好用是未知的。 先做三个原语,用出来再谈扩展它。

05六个设计问题的回答

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_toolfunction.py:963)调的就是@function用的那个_build_and_register_tool_runtime.py:1037)。同一个_registry,同一个tools数组,同样六层门禁,20个关键字参数同名同义(namedescriptionparameterslabeltoolsetunsafe_incheck_fnrequires_envcan_usedefercache等)。所以注册路径、工具面暴露、调用约定这三样差得很小。

真正差的第一样:参数schema生成器是两份,而且已经漂了。_build_parameters_schema_runtime.py:426)会读docstring的Args:段、把可选参数放宽成允许null、递归写additionalProperties:false、用get_type_hints解字符串注解、认PEP-604的X | None、认Literal_build_agentic_tool_specfunction.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:74webui/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那边压根没给注册函数传exposefunction.py:963-977),永远取True

为什么概念统一而实现不统一是可接受的:因为两个装饰器区分的从来不是概念位置。mixture_of_agents@function写了一个完全固化的多步流程,站在谱的最右端;interaction_demo@agentic_function写了一个很小的东西。装饰器选的是内部要不要记账@agentic_function的模型调用走runtime.exec,每次落一个ModelCall节点进DAG,受exposerender_range约束;@function的模型调用(mixture_of_agentscomplete_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读正文、还读categoryallowed_toolstriggersaliases、扫五个来源、同名后见先赢。三条后果都是实打实的:skills_bundled/不在喂系统提示那条路上,所以distillself-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.pyinvoke_log.jsonl,Skills页读的就是这份记录。

冲突顺序也定死了:bundledremote-cachepluginuserproject,后见先赢。用户自己写的盖过给他装的,装的盖过随程序发的;同是用户写的,项目里那份盖过家目录那份。之前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_functionsplit_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,和readgreptask并列),入参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的SkillToolToolSearchTool并排在同一个工具列表里(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不做。

改名影响面:四层,代价差一个数量级 层0 文档里怎么讲这件事 docs/ 里 125 个文件受影响(全站 428 个的 29%)。agentic_function 在 *.md 里 591 处 / 119 个文件。中英双份,实际约63组对照。 零风险,只是量大。现在就能做。 层1 加一行别名,新代码写新名 agentic_program = agentic_function。1 处改动,零风险。建议先只做这一层,让新名字先用顺。 层2 真改标识符 生产 231 处 / 58 文件,测试 146 处 / 24 文件,examples 加 scripts 52 处 / 12 文件。 from openprogram.agentic_programming 共 160 行,全是同一种写法,可以一条 sed。机械,跑全套测试即可。 层3 改模型看得见的字 program 工具的 description(program.py:42,44,每轮都在 tools 数组里)、两份 agentic-programming SKILL.md 的 frontmatter description (每轮都在系统提示里,30 和 31 处)、6 条错误串。约40处。这不是重构,是改提示词。改完模型行为会变,得重新看效果。
层4(不建议做):目录名与线上格式。openprogram/functions/agentics/被132处斜杠路径引用(61个文件),牵到 .gitignore:51,57,58pyproject.toml:201,215,216kind="agentic_function"streaming/__init__.py:24)是跨进程流协议里的枚举值。 共170处以上,改了只换来一次全仓大改,目录名和线上枚举本来就不是概念名。
另一个要一起处理的:skills/agentic-programming/SKILL.mdskills_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管不管?

不管。Sessionask_user在CLI下的文件传递目录:uuid键目录,meta.json加一个answer触发文件,原进程wait_for_answer轮询,run_with_sessionfinallycleanup()删目录。跑了半小时崩掉,它什么都不留。 真正落盘的是DAG:create_pending_call_node进入时写status=runningoutput=None的节点,_update_function_call_exit退出时补上输出和耗时,进git支撑的会话库。 每个已完成步骤的输入和输出已经在磁盘上了,只是没人读回来。最省的补法是once(key, fn),约30行,不新增写入方、不新增文件格式。上界必须说清:只对「输入决定输出」的步骤正确,写文件的步骤是被跳过不是被回放,所以要显式选择加入。

成本可预测是不是主要收益之一?

是,但要说准:固化之后不是每次调用次数固定,是次数有上界且上界写在代码里、看得见。 排在成本前面的收益其实是另外两个:漏步骤变得不可能(清单是代码,不是记性),结论可核实(出处检查是闸门,不是礼貌)。 三次真实事故里,两次是漏步骤和没核实,一次是两个agent撞同一个文件,都不是成本问题。

实现文件:openprogram/agentic_programming/function.pyruntime.pydecision.pysession.pyopenprogram/functions/_runtime.pyopenprogram/functions/agentics/openprogram/functions/tools/(技能加载动词在tools/skill/); openprogram/skills/loader.pyopenprogram/skills/tool.pyopenprogram/context/components.pyopenprogram/skills_bundled/distill/SKILL.md。 相关设计页:函数与工具调用的统一Agent协作