WORKFLOWASLIST,一种人类写给 LLM 读的工作流语言
现在的 AI Agent 是怎么干活的。
你告诉它一个目标,它自己去探索文件,自己去决定下一步做什么。它凭着系统提示词和自身的判断力在黑暗中摸索。有时候很灵,有时候只差一点,比如忘了读某个关键文件,或者多绕了很远的路。那个差的一点,往往不是能力问题,而是你不知道它到底会怎么走,它自己也不太清楚。
AGENTS.md 这类约定文件试图解决这个问题。但它是软性的,Agent 可能读,也可能忽略,没有强制语义。人和 Agent 之间缺一种确定的,共享的语言。
而每一项操作其实都可以分解为步骤。你要知道让 Agent 做什么,你要知道怎么做,你要知道每一步的输入和输出是什么。这三件事如果说不清楚,问题就不在 Agent 身上,而在你和 Agent 之间的那个接口上。
一种声明式的待办清单
WORKFLOWASLIST 是一种工作流描述语言,外观像一个结构化的待办清单。它不是用来看的 TODO,而是直接交给 LLM 执行的脚本。
这种语言只有四种基本行类型。# 开头的行是注释,: 开头的行定义变量,- 开头的行是执行步骤,> 开头的行导入外部模块。就这四种,没有更多。
一个完整的 WORKFLOWASLIST 文件只包含这几类行,按顺序排列。LLM 不需要任何解析器就能直接读懂它并按行执行。语言规范本身用 EBNF 写了,但那一百多行的定义是给人看的文档,LLM 不需要它。
下面是一个例子,生成一条 git commit message。
# example/git/commit/gen-git-commit-message.waf
# Demo,从暂存区 diff 生成 Conventional Commits 消息
- (context) ! python scripts/utils/commit_context.py
- (gen) ${context} 为这份 diff 写一条 Conventional Commits 提交消息,不需要解释
第一步获取 git diff 上下文,用 (context) 标签捕获输出。第二步把上下文传给 LLM,要求它按规范生成消息。所有的输入输出都确定了,没有多余动作。
如果换成用 Agent 来干这件事,Agent 会自己跑 git diff,也许还会看看项目结构,读读历史提交,甚至运行测试。这些步骤也许有用,也许没用,但 token 已经花掉了。
WORKFLOWASLIST 的定位是三条。你知道 Agent 能做什么,你明确要 Agent 做什么,你知道每一步该怎么做。它类似 Specification-Driven Development 的逆向版,不是先生成规范再实现,而是先有规范再驱动执行。它和其他开发范式不冲突,是互补关系。
语法里的几个设计点
步骤有三种执行模式。
普通的 - text 直接把文本作为 AI 消息发送。- ! command 执行 shell 命令并返回成败。- ? question 让 AI 回答是或否。这三种模式覆盖了绝大多数自动化场景中需要用到的操作类型。
标签和跳转给了这种语言控制流能力。
(tag) 在步骤前加标签,那个步骤的输出就可以被后续引用。@tag 写在 ! 或 ? 之前,表示如果这一步成功就跳转到对应标签,如果失败就继续往下走。标签和跳转组合起来,就能写分支和循环了。下面是一个示意。
:attempt = 0
- (init) ! python
setup.py
-
@init ? 初始化成功了吗
- (run) 执行主流程
-
@retry ? 结果正确吗
嵌套缩进则提供了子流程能力。每个层级缩进两个空格,被缩进的步骤附属于它的父步骤。如果把多级缩进看作栈结构,把 @tag 跳转看作 call,那么这份看起来像清单的文本,已经具备了编程语言的控制流原语。
${reference} 是步骤之间的数据管道。${tag} 替换为标签对应步骤的输出,${tag ! command} 把输出通过 shell 命令做一次管道变换。比如 ${ip ! jq -r '.city'} 就是取出 IP 信息里的城市字段。没有引号嵌套问题,没有转义需求,文本一直写到行尾就是全部内容。
(alias) path 导入外部模块,别名就是命名空间,内部标签通过 alias.tag 访问。路径可以是本地文件,也可以是 URL。这使得工作流可以跨项目,跨网络复用。
语言层面的成本优势
这一点可能是 WORKFLOWASLIST 最有趣的设计属性。
LLM API 有一个机制叫前缀缓存。简单说,如果多次请求共享同一段开头的 token 序列,服务端可以复用中间计算结果,省掉推理成本。WORKFLOWASLIST 文件是声明式且确定性的,同一个 .waf 文件被不同用户,不同时间执行时,其 message list 的开头部分是固定的。
当一个工作流被小规模群体采纳,这个群体内的每次执行都在贡献同一个前缀。随着使用者增多,缓存命中率随之上升。对 API 提供商来说,这是天然的高缓存命中流量。对使用者来说,缓存命中的请求更快也更便宜。
这里有一种网络效应。不需要等厂商单独为你的应用做优化,也不需要大规模普及,只要有一定数量的人用同一个工作流文件,这个群体里每个人都能享受到缓存带来的成本下降。某种意义上这是一种公约,一种通过共享确定性行为来集体降低成本的机制。
与之相对,传统 Agent 的 message list 是动态生成的。每一步一个选择分支,整条链路的 token 序列几乎不可能重复。前缀缓存很难命中。
语言本身是核心
当前项目有一个基于 Python 的参考实现,可以跑起来看效果。但实现本身不是重点,语言才是。
WORKFLOWASLIST 的语法规范在 SYNTAX.ebnf 里写得很清楚,可以作为任意语言实现的标准。它不绑定于 Python,也不绑定于任何特定的 LLM 框架。只要有办法读文本,发 HTTP 请求,执行 shell 命令,就能实现这套语言。
这是一种专注于表达的语言。它不解决推理问题,不解决模型选择问题,它只解决一个问题,你和 LLM 之间用什么格式交代任务。你的思路如果是清楚的,这份清单就是你思路的直接映射。
现状与合作
项目目前处于 demo 阶段。语法规范已经定了,核心概念已经跑通了,但距离成熟的实现和生态还有很长的路。
我一个人无法完整完成这个项目的全部构想。语言的设计可以一个人做,但语言需要生态,生态需要社区。如果你对这种思路感兴趣,无论是想讨论语法设计,写其他语言的实现,分享自己的 .waf 工作流,都欢迎联系。
GitHub 地址是 D7x7z49/waf,规范文件在仓库根的 SYNTAX.ebnf,文章末尾有附加。
哪怕是只有一小部分人开始用同一个工作流文件,这个小群体的 API 成本就已经在下降了。这是我想验证的一件事。
(* ========================================================================= *)
(* WORKFLOWASLIST FORMAL SYNTAX SPECIFICATION *)
(* ========================================================================= *)
(*
REFERENCE.
ISO/IEC 14977:1996(E) - Extended Backus-Naur Form
NOTE.
This EBNF specification is for documentation only.
WorkflowAsList requires no parser. LLM reads and follows directly.
DESIGN PRINCIPLES.
- Concise. Information Theory. Remove redundancy, preserve meaning.
- Practical. Set Theory. Bounded scope, minimal complete.
- Elegant. Logical Flow. Logical coherence, consistent ordering.
- Uniform. Text Stream. All content is text, identical syntax for same things.
*)
(* ========================================================================= *)
(* SECTION 1. TOP-LEVEL STRUCTURE *)
(* ========================================================================= *)
workflow = { line } ;
line = line_content , eol ;
eol = "\n" | "\r\n" ;
(* ========================================================================= *)
(* SECTION 2. LINE CONTENT *)
(* ========================================================================= *)
(*
LINE CONTENT.
- Top-level lines are empty, comment, import, variable, or step.
- Indented lines are only comment or step. Each indent level adds two spaces.
*)
line_content = empty_line
| comment_line
| import_line
| variable_line
| step_line
| indented_content ;
indented_content = indent , ( comment_line | step_line ) ;
indent = { " " } ;
(* ========================================================================= *)
(* SECTION 3. EMPTY LINE *)
(* ========================================================================= *)
empty_line = ;
(* ========================================================================= *)
(* SECTION 4. COMMENT LINE *)
(* ========================================================================= *)
comment_line = "#" , plain_text ;
(* ========================================================================= *)
(* SECTION 5. IMPORT LINE *)
(* ========================================================================= *)
(*
FORMAT.
Path is local (relative or absolute) or a remote URL.
Remote URLs start with http. or https.
Resolution is runtime-handled.
USAGE.
The alias creates a namespace.
Internal tags are accessed via alias.identifier, e.g. ${lib.tag}.
*)
import_line = ">" , "(" , identifier , ")" , path ;
path = plain_text ;
(* ========================================================================= *)
(* SECTION 6. VARIABLE LINE *)
(* ========================================================================= *)
variable_line = ":" , identifier , "=" , text ;
(* ========================================================================= *)
(* SECTION 7. STEP LINE *)
(* ========================================================================= *)
(*
Step execution is determined by control prefix.
Exclamation invokes shell and returns success or failure.
Question invokes agent and returns true or false.
Both forms produce a boolean result.
If reference is present, the boolean result controls jump.
True causes jump to the target tag.
False continues to next step.
Reference in this context resolves to a step tag only.
It is not used as a value.
*)
step_line = "-" , [ tag ] , [ control_prefix ] , text ;
tag = "(" , identifier , ")" ;
control_prefix = ( "@" , reference , ( "!" | "?" ) ) | ( "!" | "?" ) ;
(* ========================================================================= *)
(* SECTION 8. TEXT AND PLAIN TEXT *)
(* ========================================================================= *)
text = { text_element } ;
text_element = plain_text | substitution ;
(* ========================================================================= *)
(* SECTION 9. SUBSTITUTION *)
(* ========================================================================= *)
(*
Substitution resolves a reference to text.
The result is inserted into the surrounding text.
If exclamation is present, a shell transformation is applied.
The reference value is passed as input to the command.
The command output replaces the substitution.
Exclamation here does not produce a boolean result.
It performs text transformation only.
*)
substitution = "${" , reference , [ "!" , text ] , "}" ;
(* ========================================================================= *)
(* SECTION 10. IDENTIFIERS AND REFERENCES *)
(* ========================================================================= *)
(*
Reference is use-side dotted path, used in ${ref} and
@ref.
Identifier is definition-side name, used in tag, variable, and alias.
*)
reference = identifier , { "." , identifier } ;
identifier = letter , { letter | digit | "-" } ;
(* ========================================================================= *)
(* SECTION 11. BASIC CHARACTER CLASSES *)
(* ========================================================================= *)
letter = "A" - "Z" | "a" - "z" ;
digit = "0" - "9" ;
plain_text = { ? regex:[^\r\n] ? } ;
(*
Symbol is any printable ASCII character except letter, digit, and newline.
Space is allowed.
*)
symbol = ? printable ASCII character except letter, digit, newline ? ;
(* ========================================================================= *)
(* SECTION 12. CONSTRAINTS AND RULES *)
(* ========================================================================= *)
(*
- RULE 1. Every non-empty line starts with : or - or > after whitespace removal.
- RULE 2. Step syntax is - [(tag)] [
@ref] [!/?] text.
- RULE 3. If
@ref present, ! or ? must follow immediately.
- RULE 4. ! means shell command, ? means boolean question.
- RULE 5. No special prefix means plain agent message.
- RULE 6. Text is never quoted. It runs to end of line.
- RULE 7. Variable values are text. No quoting or type distinction is required.
- RULE 8. ! text in steps and ${ref ! text} share the same text definition.
- RULE 9. ? constrains agent to reply true or false.
- RULE 10. > (alias) path imports a .workflow.list file under a namespace.
- RULE 11. Nesting uses 2 spaces per level. Only steps and comments may indent.
- RULE 12. Variables and imports are always top-level, no indentation.
- RULE 13. identifier is for definition, tag, variable, alias.
- RULE 14. reference is for use, ${ref},
@ref. It supports dot-path access.
*)
(* ========================================================================= *)
(* SECTION 13. EXAMPLES *)
(* ========================================================================= *)
(* EXAMPLE 1. Variable definition
:retries = 0
:greeting = hello
*)
(* EXAMPLE 2. Plain step (agent message)
- Read project structure
*)
(* EXAMPLE 3. Step with tag
- (analyze) Analyze source code
*)
(* EXAMPLE 4. Step with shell command
- ! ls -al
*)
(* EXAMPLE 5. Step with boolean question
- ? Data backed up, continue?
*)
(* EXAMPLE 6. Step with jump target and question
- (check)
@retry ? Errors fixed?
*)
(* EXAMPLE 7. Step with jump target and command
-
@init ! python
setup.py
*)
(* EXAMPLE 8. Variable with substitution
:result = ${analyze ! grep "error"}
:count = ${retries}
*)
(* EXAMPLE 9. Import with alias
> (utils) ./lib/utils.workflow.list
> (remote)
example.com/wf/shared.workfl…
*)
(* EXAMPLE 10. Nested steps with inline substitution
- (diff) ! git diff --cached
- (gen) Generate commit message for. ${diff}
-
@gen ? Accept this message?
- ! git commit -m "${gen}"
*)
(* EXAMPLE 11. Full workflow with import and dot notation
:attempt = 0
> (tasks) ./common/tasks.workflow.list
- (init) Initialize environment
- ! python
setup.py
- (run) Run main process
- ? Ready to proceed?
-
@tasks.run !
execute.sh
-
@init ? Setup correct?
*)
(* ========================================================================= *)
(* END OF SPECIFICATION *)
(* ========================================================================= *)