基于文件夹和Markdown文件构建的本地LLM智能体工作流
构建一个带门控机制的多阶段本地代理流程,每个阶段对应一个文件夹,提示词为可编辑的 Markdown 格式,并在执行昂贵操作之前由人工审核输出结果。
来源: Build Your Own Local LLM Agent Workflow in 400 Lines of Python
本地智能体无需依赖任何框架即可实现可重复、可检查的工作流程。这里的设置会运行一个多阶段任务,每个阶段对应一个文件夹,每个阶段的提示词都是普通人能够阅读和编辑的普通 Markdown 文件,同时会有人在阶段之间审核输出结果。智能体本身不会改变,只有其上下文存储方式会发生变化,这才是关键所在。
为什么本地智能体需要这种方式
该智能体已经能够处理网页搜索、页面阅读以及通过持久化的浏览器标签页执行操作,但此前缺乏一种能够重复运行任务并让人在中途进行检查的方法。
问题的根源在于所有控制它的机制都存在于代码中。系统提示在导入时被生成,而工具列表则在启动时创建,并在每次交互时传递给模型。若要让智能体在不同步骤中扮演不同角色,就必须修改Python代码。在执行成本较高的步骤之前,也不存在可以暂停并查看中间结果的机制。这种设计适用于聊天循环,但对于需要分阶段完成的任务来说则很不方便。
由此诞生了一种简洁的课程研究流程:四个阶段将描述课程的简单句子逐步发展为初步的课程草案,每个阶段之后都有审核环节。现在,用纯Markdown格式组织的带编号文件夹用于存储控制智能体的上下文,这样在开始起草之前就可以阅读和修改大纲,且整个过程中无需修改任何代码。
该结构采用一种名为“可解释上下文方法论”(Interpretable Context Methodology,简称ICM)的方案,用文件系统结构替代框架级的协调机制。
图表中将工作流程展示为一个名为workspace的文件夹。pipeline/下的每个带编号的文件夹代表一个阶段,每个阶段的CONTEXT.md文件即为该阶段的提示词,而proposed/则是人工手动提交更改的暂存区。
带编号的文件夹充当阶段角色,Markdown文件存储各阶段的上下文信息,本地脚本则处理无需模型参与的机械性任务。这一设计借鉴了Unix管道和多遍编译器的原理,即前一步的输出会作为下一步的输入,且每个中间结果都是可直接打开的普通文件。
运行所需环境
对于该模型,qwen3.5:9b在Ollama环境下运行时需关闭思考功能并将温度参数设置为0.1。浏览和搜索功能会复用之前的实现:camofox-browser在9500端口监听,SearXNG在8090端口监听,二者均运行在Docker容器中。在开始之前,需先启动这两个容器以及Ollama和能够支持工具调用的模型。
构建过程分为五个阶段:
- 第一阶段在Docker中启动camofox-browser和SearXNG,后续阶段会调用这些本地服务。
- 第二阶段从Markdown文件文件夹中根据各阶段需求生成智能体的系统提示语,并替换其中一个硬编码的字符串。
- 第三阶段提供驱动程序,同时为每个阶段设置审核机制和工具使用限制。
- 第四阶段是方案审核阶段:一个阶段会结合前两个阶段的输出结果,由人工对后续的昂贵操作方案进行审批。
proposed/文件夹,为后续扩展预留空间。安装方法
每个阶段都位于独立的子目录中,并通过pyproject.toml以控制台脚本的形式呈现,因此安装完成后即可通过名称运行任意阶段。第一步是克隆仓库,并使用虚拟环境以可编辑模式进行安装:
git clone https://github.com/jfjensen/local-LLM-agent-icm-workflow.git
cd local-LLM-agent-icm-workflow
python -m venv .venv
# Windows PowerShell:
.\.venv\Scripts\Activate.ps1
pip install -e .
该安装会引入 mcp、ollama 和 httpx,并注册一组控制台脚本。第2阶段到第5阶段分别会配备一个工作流运行器(mcp-workflow-stage2 至 mcp-workflow-stage5)和一个代理(mcp-agent-stage2 至 mcp-agent-stage5)。两个MCP服务器 mcp-search-part3 和 mcp-browser-stage5 是从早期版本复制过来的,这使得仓库保持自包含性。
配置信息位于仓库根目录下的 config.toml 文件中,其中新增了一个 [workflow] 部分。由于每个阶段都是相对于当前目录来读取 workspace/ 文件并写入 history/ 文件的,因此最好在对应阶段的文件夹内启动该阶段。
cd stage5
mcp-workflow-stage5
第一阶段:本地服务
此阶段不涉及 Python。它会启动代理在研究过程中使用的两项服务:camofox-browser,即承载读取工具的隐身浏览器;以及 SearXNG,即 search 功能背后的搜索引擎。已经完成前期设置的用户可以直接使用相同的 docker-compose.yml 文件。
cd stage1
docker compose up -d
docker compose logs -f
有几点细节值得了解:
- Camofox在9500端口上运行,SearXNG在8090端口上运行,这两个正是
config.toml所指定的端口,因此代理无需额外设置即可同时找到它们。 - compose文件会绑定挂载
searxng/settings.yml,这样就能启用JSON API并关闭速率限制,从而在首次运行时即可进行搜索。 - Camofox镜像是从其上游仓库一次性构建的。由于Camoufox二进制文件体积较大,首次构建速度较慢,
stage1/README.md中列出了相关步骤。
要检查服务状态,可使用curl发送请求http://localhost:9500/health和http://localhost:8090/search?q=ollama&format=json,两者都应返回JSON格式的响应。
第一阶段仅包含这两个服务。一旦Camofox在9500端口上响应,SearXNG在8090端口上响应,研究工具就有了可调用的服务。
第二阶段:从文件中获取上下文
这是唯一的实际代码改动。之前的智能体只携带一个系统提示,即在启动时生成的模块级字符串,无论执行何种任务都会原封不动地被使用。要让它在不同阶段承担不同角色,就必须让提示来自可编辑的来源,而从文件中读取则是最简单的方法。
因此,第一个改动就是允许覆盖系统提示:
def build_messages_for_model(self) -> list[dict[str, Any]]:
system = self.system_text if self.system_text is not None else SYSTEM_PROMPT
return [{"role": "system", "content": system}] + self.messages
其工作原理很简单:
- 如果未设置
system_text,代理会恢复使用之前固定的SYSTEM_PROMPT,因此交互式代理mcp-agent-stage2的行为会与以往完全一致。 - 当设置了
system_text后,该值将成为本次运行的系统提示语。工作流运行器会按阶段从文件中读取该值,而代理类本身则不会受到影响。
至于文件本身,ICM 工作空间只不过是一个文件夹。stage2/workspace/ 中包含描述工作空间及其布局的 CLAUDE.md、按顺序列出各阶段的 CONTEXT.md、存放运行间保持不变的参考资料的 _config/ 目录,以及包含编号阶段的 pipeline/ 目录。每个阶段目录都有属于自己的 CONTEXT.md 规范以及一个 output/ 目录。
这两个顶层文件负责确定身份与路由规则。CLAUDE.md 属于第 0 层,是代理首先读取的文件:
# Workspace: course-research
You are a single agent operating inside an ICM workspace. This file tells
you where you are and how the workspace is laid out. Read it first.
This workspace turns a one-line course idea into a drafted mini-course,
through four stages that run one at a time with a human review between each.
Layout:
- `CONTEXT.md` (next to this file): the workspace routing. It lists the
stages in order and the shared resources they draw on.
- `_config/`: shared reference material that stays the same across runs
(the audience profile, the writing voice). This is the factory.
- `pipeline/`: the numbered stages. Each stage folder holds a `CONTEXT.md`
contract, an `output/` folder for what that stage produces, and
sometimes a `references/` folder for stage-local reference material.
- `pipeline/proposed/`: a staging area, not a stage. A human moves
improvements from here into `_config/` once they are happy with them.
You only ever work on one stage at a time. The stage's own `CONTEXT.md`
tells you exactly what to read, what to do, and what to write. Do not read
files the stage contract does not list.
在其旁边,CONTEXT.md 是第一层,即列出各阶段的路由配置:
# Routing
The course-research pipeline runs in four stages, in this order:
1. `01-scope`: turn the one-line course idea and the audience profile into
a short list of scoped learning objectives.
2. `02-research`: for each objective, search the web and read sources, and
write structured notes with links. This is the only stage that uses
tools.
3. `03-outline`: turn the objectives and the notes into a module-by-module
course outline. This is the gate before the expensive stage, so the
outline is worth a careful human read.
4. `04-draft`: draft each module from the approved outline and the notes.
Each stage reads from the stage before it (its `output/` folder) and
writes to its own `output/` folder. A human reviews each output before the
next stage runs.
Shared resources (in `_config/`, the same every run):
- `audience.md`: who the course is for. Every stage should respect it.
- `voice.md`: how the course should read. The draft stage follows it.
共享的参考资料存放在 _config/ 目录中,只需配置一次即可在每次运行时使用。下面是 audience.md,这是一个第三层文件,范围确定、研究阶段以及大纲制定阶段都会读取它:
# Audience
The course is for working software developers who are comfortable with
Python and the command line, but who have not run a language model on
their own machine before.
They want practical, hands-on knowledge they can apply the same day, not
a survey of the field. Assume they can install software, edit a config
file, and read a stack trace. Do not assume they know any machine-learning
theory, model formats, or GPU terminology.
Keep examples concrete and runnable. Prefer one worked example over three
abstract options. When a trade-off matters, state it plainly and give a
default.
01-scope/CONTEXT.md 文件是该设计的核心。这是第一个处理阶段的所有相关内容:
# Stage: 01-scope
## Inputs
- Layer 4 (working): input.md
- Layer 3 (reference): ../../_config/audience.md
## Tools
none
## Process
Read the one-line course idea and the audience profile. Produce a short
list of scoped learning objectives for the course, four to six of them.
Each objective should be one sentence, start with a verb, and describe
something the learner will be able to do by the end. Keep the scope tight:
this is a short course, so leave out anything that does not fit a learner
going from zero to a working local model. Do not pad the list to hit a
number. Fewer, sharper objectives are better.
Write the objectives as a numbered markdown list, nothing else.
## Outputs
- objectives.md -> output/
唯一的第4层输入文件 01-scope/input.md 是本次运行的工作材料,也就是那个被发展成课程的简短创意:
# Course idea
A short, practical course that takes a developer from zero to running a
local large language model on their own machine with Ollama, and calling
it from a small Python script.
关于如何解读该合同的几点说明:
- “输入”表格列出了各阶段需要读取的文件,并为每个文件标注了层级:层3用于存储在多次运行中都保持不变的参考资料(即受众画像),层4用于存储当前运行所需的工作材料(即一句话概括的核心思路)。工作区中不会加载其他任何文件。
- “工具”一栏显示为
无,因为将思路转化为具体目标并不需要任何工具。第三阶段会回到此步骤。 - “流程”部分记载了智能体需遵循的指令,这些指令随后会轮到用户执行。
- “输出”一栏列出了该阶段会写入其自身
output/文件夹中的文件名称。
根据合同规定,加载器会将输入表转换为实际的文件内容,并以此构建上下文。参考信息与工作材料是分开的,因为它们承担不同的功能:前者是一组模型应当遵循的规则,后者则是需要被转换的原始输入。
# System text: identity + routing + the reference material (Layer 3).
parts = []
if layer0:
parts.append(layer0.strip())
if layer1:
parts.append(layer1.strip())
refs3 = [r for r in inputs if r.layer == 3 and r.exists]
if refs3:
block = ["# Reference material (follow these as constraints)"]
for r in refs3:
block.append(f"\n## {r.rel_path}\n\n{r.text.strip()}")
parts.append("\n".join(block))
system_text = "\n\n".join(parts).strip()
加载器的运作方式如下:
- 首先是身份与路由信息(
CLAUDE.md、CONTEXT.md),用于告知智能体当前所在位置以及存在的阶段。 - 第三层参考文件会以约束条件的形式出现在相应标题下;第四层工作文件则单独列出,位于该阶段的“处理流程”文本下方,作为智能体应当执行的操作。
- 最终生成的便是该阶段的系统提示语,它由纯文本文件构成,不会包含该阶段未要求的内容。
在将任何内容输入模型之前,先检查加载器生成的输出是很有必要的,因为这一部分是确定性的。仓库中提供了一个名为 probe_loader.py 的小型检测工具,它可以打印出某个阶段的整合后上下文并给出大致的标记数,且不会调用模型。请从 stage2 文件夹中针对第一个阶段运行该工具:
cd .\stage2\
python ..\probe_loader.py 01-scope
截图显示了探针针对范围阶段报告的开头内容以及上下文窗口的大小。该阶段最终会产生约900个相关令牌,这使得ICM理念变得具体可行:每个阶段都有一个较小的上下文窗口,而非使用一个庞大的提示词来包含所有内容。
要实际运行该阶段,可使用mcp-workflow-stage2。它会解析合同,运行一次代理,并生成output/objectives.md文件。以示例中的想法——关于如何使用Ollama运行本地大语言模型的简短实用课程为例,它给出了五个条理清晰的目标,人们可以快速浏览后决定保留或舍弃。
截图中的横幅显示了探针预测的上下文范围,约为900个令牌,而该阶段的结果则是objectives.md文件。一次运行产生的目标会显示在下方;再次运行时,这些目标的表述可能会略有不同:
1. Install the necessary software stack (Ollama) and verify it is successfully pulling and serving a local LLM like `llama3`.
2. Configure environment variables to securely expose the local model's API endpoint for external access.
3. Write a Python script using the `requests` library to send prompts to Ollama and parse JSON responses into readable text.
4. Implement error handling in your script to gracefully manage connection timeouts or out-of-memory errors common with local inference.
5. Build an interactive loop that allows you to chat with the model directly from a terminal-based Python application without using a GUI framework.
既然上下文来自文件,现在还缺少的就是按顺序执行各阶段并在其间暂停。
第三阶段:驱动程序、门控机制与工具范围界定
工作流意味着按顺序进行的各个阶段,且阶段之间需有人工检查点。因此,驱动程序会扫描编号过的文件夹,运行第一个阶段并生成空输出后暂停。
async def run_pipeline(workspace_dir: Path, run_all: bool = False) -> None:
stages = stage_dirs(workspace_dir)
for sd in stages:
if stage_is_done(sd):
print(f"[skip] {sd.name}: output already present")
continue
await run_stage(workspace_dir, sd.name)
if run_all:
continue
# The review gate. Stop here so the human can read and edit the
# output before the next stage reads it.
print("\n--- review gate ---")
print(f"Review and edit if needed:\n {sd / 'output'}")
return
工作原理:
- 状态存储在磁盘上。当某个阶段的
output/文件夹中存在实际文件时,该阶段即视为已完成,因此无需单独的状态存储机制——文件系统本身就是状态载体。 - “门控”功能仅用于提前终止流程。某个阶段完成后,驱动程序会指示下一步该查找何处并停止执行。此时可读取输出结果,根据需要对其进行修改,然后再次运行相同的命令以继续流程。
--all是一种直接跳过所有检查的选项,适用于您信任处理结果的情况。默认情况下会启用门控机制,因为这正是门控功能存在的意义。
要运行该流水线,需使用 mcp-workflow-stage3,读取其生成的输出内容,然后再运行一次以推进流程。第一次运行会处理 01-scope 部分并停止;第二次运行会处理研究相关部分并停止,依此类推。
每次调用处理一个阶段,会指出需要查找的位置,然后在审核环节停止;再次运行相同命令即可继续处理。
工具范围
研究阶段是唯一会使用工具的环节,也是最初出现问题的地方。该代理程序会连接到搜索服务器和浏览器服务器,早期版本会将所有服务器上的工具合并成一个扁平列表,并在每次轮次中提供整个列表。这些工具大约有十五个,而研究阶段实际只需要五个左右。其余的都是用于操作页面的标签工具和动作工具,研究阶段根本不会使用它们。
9B模型虽具备全部十五种功能,但实际仅使用了交互工具类功能。它在各种阅读器功能中选择了open_tab和read_tab,而read_tab仅能显示页面顶部内容。由于Ollama的文档均托管在Mintlify上,因此各处的页面顶部内容完全一致,导航文本也相同。该模型持续读取该菜单并依次打开各个标签页,直到调用次数限制触发停止,最终输出一段143个字符的道歉信息。
解决此问题的方法是按不同阶段限定工具的使用范围。通过合同明确列出所需工具,运行时仅暴露这些工具。以下是完整的研究合同内容:
# Stage: 02-research
## Inputs
- Layer 4 (working): ../01-scope/output/objectives.md
- Layer 3 (reference): ../../_config/audience.md
- Layer 3 (reference): references/tool-use.md
## Tools
search-server: search
browser-server: summarize, extract, fetch_snippet, fetch_urls
## Process
For each learning objective, find and read one or two good current sources,
then write short, factual notes the outline and draft stages can rely on.
Work objective by objective. For each one: run a web search, then read the
most promising result with `summarize` or `extract`. Use `summarize(url,
question=...)` for an open question about a page, and `extract(url, schema)`
when you can name the fields you want (a version number, a default port).
These read the whole page; do not try to page through a site by hand. Read
each page at most once and do not re-read a page you have already read. Once
you have one or two sources per objective, stop calling tools and write the
notes. Pull out the concrete facts that matter for teaching this objective
(commands, version notes, default ports, common pitfalls). Prefer official
documentation and project sources over blog aggregators.
Write the notes as markdown, grouped under a heading per objective. Under
each heading, a few bullet points of facts, then a "Sources:" line with
the URLs you actually read. Keep it factual. Do not draft course prose here;
that happens later.
## Outputs
- notes.md -> output/
新的工具板块中,搜索服务器仅提供search功能,而浏览器服务器则仅有四种工具,均为阅读器类工具。在模型获取工具列表之前,运行时系统会先对其应用过滤条件:
def rebuild_ollama_tools(self, allowed: dict[str, set[str] | None] | None = None):
out = []
self._tool_to_server = {}
for server_name, tools in self.mcp_tools_by_server.items():
allow_set = allowed.get(server_name) if allowed else None
for tool in tools:
if allow_set is not None and tool.name not in allow_set:
continue
prefixed = f"{server_name}_{tool.name}"
self._tool_to_server[prefixed] = server_name
out.append({
"type": "function",
"function": {
"name": prefixed,
"description": tool.description or "",
"parameters": tool.inputSchema,
},
})
self.ollama_tools = out
过滤器的运作原理如下:
- 允许列表是针对每台服务器独立的。若设置为
None,则表示该服务器的所有工具均可使用;而指定名称列表则仅允许这些工具使用。 - 任何不在允许列表中的工具都会被丢弃,模型因此无法看到它们。目前研究阶段识别出五种工具,而scope、outline和draft则无法识别任何工具。
- 该映射是逐步重建的,因此隐藏的工具也无法被调用;若出现虚构的
open_tab调用,也会被识别为未知工具。
研究项目还提供了自己的第三层参考文档references/tool-use.md,目前仅适用于此阶段,其中说明了如何使用剩余的读取器:
# Tool use (research stage only)
You have two MCP servers for this stage. Use them like this.
- `search-server_search(query, max_results)`: a web search. Start here for
any lookup. It returns URLs with titles and snippets. After a search,
your next action must be a browser-server call on the most promising URL.
Do not stop after a search and do not answer from the snippets alone.
The browser-server reader tools each return a small slice of a page, not
the raw page:
- `browser-server_fetch_snippet(url)`: the head of a page, for a quick look.
- `browser-server_fetch_urls(url)`: the page's links, to decide what to read next.
- `browser-server_fetch_structure(url)`: the page's heading outline.
- `browser-server_extract(url, schema)`: pull named fields you can list in
advance (a version number, a default port) using a JSON Schema.
- `browser-server_summarize(url, question="")`: a prose summary of a whole
page; pass `question` to focus it on what this objective needs.
Prefer `extract` when you know the field names, `summarize` for an open
question about a long page, and `fetch_snippet` for a quick confirmation.
Prefer official documentation and project sources over blog aggregators.
文件系统再次承担起框架的角色。在框架中,智能体的工具集是代码;而在这里,它只是任何人都可以编辑的一页 Markdown 文本。在关闭了阅读器工具后,名为 summarize 和 extract 的模型通过分块阅读器读取整页内容,而笔记中则记录了实际发现:安装命令、默认端口 11434 以及如何使用 Python 库,每条内容都附有来源链接。
当模型无法停止时
第二个问题更为微妙。尽管拥有合适的工具,模型能够读取优质页面,却始终无法判断何时该停止。有次运行中,同一页面被反复总结,相同的问题被提出十几次,直到调用次数达到上限才有所进展。有用的发现都存在于消息历史中,但由于没有给出最终答案,输出的结果依旧是道歉信息。
为了解决这个问题,每当一轮对话被提前结束时,运行程序会多执行一步操作:在不使用任何工具的情况下进行最后一次调用,指示模型根据已收集的信息来撰写答案。
async def _finalize(self, stop_reason: str) -> dict:
if not self.synthesize_on_stop:
self.messages.append({"role": "assistant", "content": stop_reason})
return {"role": "assistant", "content": stop_reason}
self.messages.append({
"role": "user",
"content": ("Stop using tools now. Using only the information you have "
"already gathered above, write the final output the task "
"asked for. Do not call any tools."),
})
resp = ollama.chat(model=MODEL_NAME, messages=self.build_messages_for_model(),
options={"temperature": MODEL_TEMPERATURE}, think=MODEL_THINKING)
msg = resp["message"]
content = msg.get("content", "") if isinstance(msg, dict) else getattr(msg, "content", "")
final = {"role": "assistant", "content": content or stop_reason}
self.messages.append(final)
return final
有三个要点需要注意:
- 这是可选功能。交互式代理会保持处于关闭状态,像以前一样仅报告停止信息,而工作流会将其打开,确保每个阶段都能输出实际内容。
- 它能挽救工作进度。工具的运行结果已经记录在历史中,因此无需工具的调用就可以从中提取笔记,将出错的对话转化为可用的
notes.md文件,而非仅留下道歉之词。 - 循环检测机制也很有帮助。现在,如果同一调用连续执行三次,无论是否有错误都会提前结束当前对话,从而减少模型在合成开始前的空转时间。
一次完整的研究过程大约持续七分钟,为五个目标生成了超过10,000个字符的参考笔记。
截图显示第二个工具正在完成处理,并要求对输出结果进行审核。在设置了各项检查点、明确了研究范围并完成了初步整理之后,研究结果已足够可靠,可以在此基础上进一步推进,而接下来的检查点最为重要。
第4阶段:大纲阶段
在所有阶段中,大纲阶段的处理最为关键,因为紧接着就是起草环节,而起草是成本最高的步骤。在大纲阶段发现的错误最容易修正。该阶段会将之前的两个输出结果——即01-scope中的目标与02-research中的研究笔记——整合成按模块划分的大纲。合同中的规定如下:
# Stage: 03-outline
## Inputs
- Layer 4 (working): ../01-scope/output/objectives.md
- Layer 4 (working): ../02-research/output/notes.md
- Layer 3 (reference): ../../_config/audience.md
## Tools
none
## Process
Turn the objectives and the research notes into a module-by-module course
outline. One module per objective is a reasonable default, but merge or
split where the notes suggest it.
For each module, give a short title and three to five bullet points naming
what it covers, drawn from the notes. Order the modules so each one builds
on the one before it. Note any module that depends on something from an
earlier module.
This outline is the last checkpoint before the course is drafted, so make
it easy for a human to read and correct. Write it as markdown headings with
bullet points. Do not draft the module text yet.
## Outputs
- outline.md -> output/
那两条第4层线路是新的添加内容。它们使得ICM论文中描述的多阶段传递机制得以实现,因为某一阶段的输出会作为输入传递给下一阶段,而当前阶段则会整合前两个阶段的成果。加载器会将这两部分内容作为处理材料,模型则据此生成概要。
使用mcp-workflow-stage4来启动它。由于相关范围和研究工作已经完成,该流程会执行03-outline步骤,生成outline.md文件,然后在检查点处暂停。
运行mcp-workflow-stage4后会生成概要,并在检查点处停止——这是起草之前最后可以低成本调整方向的环节。
这个框架立刻展现出了它的价值。有一次在数据收集过程中,获取的信息量过大。该课程原本是为那些从零开始学习运行模型及编写简单脚本的人设计的,但笔记中却出现了云API密钥和KV缓存量化等相关内容——这些其实是模型从文档中看到的信息并被如实记录了下来。后续的课程大纲沿用了这些内容,甚至还新增了一个名为“利用环境变量保障连接安全”的模块,里面全是初学者并不需要的内容。
整个工作流程本身并没有出现问题。那些多余的内容只是以普通文件中可读的标题形式出现,此时课程正文还未开始编写。只需删除该模块、调整其内容或将其移至其他位置,撰写过程就无需再花费时间处理它。修复方法仅需对Markdown文件进行一次编辑,而如果在撰写完成后才发现问题,则需要重新阅读五个模块以及众多课程内容才能找到那个不该出现的内容。
在入口处的 VS Code 中有一个示例大纲可供编辑,那些不在范围内的模块或课程内容会在草稿阶段出现之前就被剔除。
在入口处,将大纲缩减到所需的模块和课程数量,然后重新运行。模块数量仅取决于文件中包含的 ## Module 标题的数量,因此调整大纲也就决定了课程的长度。
第5阶段:按模块起草
起草阶段会根据已批准的大纲编写每个模块的内容,同时从笔记中提取相关事实。最初的尝试要求一次性完成整个课程的编写,但并未成功:有些运行会生成四个模块,有些则只生成一个模块后就宣称任务完成。小型模型会将“编写整个课程”视为一项巨大的任务,因而倾向于提前停止。
因此,草稿阶段改为逐个处理模块。其完整流程包含一个声明循环的 For each 指令:
# Stage: 04-draft
## Inputs
- Layer 4 (working): ../02-research/output/notes.md
- Layer 3 (reference): ../../_config/voice.md
- Layer 3 (reference): ../../_config/audience.md
## Tools
none
## For each
module in ../03-outline/output/outline.md
## Process
Draft the one module shown below, and only that module. Use the research
notes for the facts (commands, ports, pitfalls) rather than inventing them,
and follow the voice and audience guides. Write a few short paragraphs of
teaching prose plus any commands the learner should run. Do not write the
other modules. Start your output with the module's heading, and output only
the drafted module, nothing else.
This is a first draft for a human to revise, so aim for a clear, correct
scaffold rather than a finished, polished module.
## Outputs
- draft.md -> output/
草稿阶段是唯一会读取 voice.md 的阶段,这是工作区中的最终第三层文件,用于确定课程的整体基调:
# Voice
Write plainly, as one developer explaining something to another at the
next desk. Short instructional sentences, followed by a longer one that
explains why.
- Lead with what to do, then explain the reasoning.
- Use concrete names: real tools, real commands, real file paths.
- Hedge honestly when something is a judgement call or can fail.
- No marketing language. No hype. No "unlock", "seamless", "robust".
- One idea per paragraph. Bullets only for listing the parts of a thing.
For each语句将一个开放式轮次转换为循环结构,运行时会在每个模块中执行一次该处理过程:
fe = ctx.for_each
print(f" for each: {len(fe.modules)} {fe.item}(s) from {fe.rel_path}")
pieces = []
for i, (heading, body) in enumerate(fe.modules, 1):
print(f" [{i}/{len(fe.modules)}] {heading}")
agent.messages = []
item_block = (f"# The {fe.item} to write now\n\n"
f"## {heading}\n\n{body}")
agent.messages.append({"role": "user", "content": ctx.user_text + "\n\n" + item_block})
final = await agent.run_turn()
pieces.append((final.get("content", "") or "").strip())
content = "\n\n".join(p for p in pieces if p)
循环的功能:
- 大纲会在
##标题处被拆分成多个模块,循环会为每个模块执行一次。次数并非固定为五次;四个模块意味着四次迭代,六个模块则对应六次迭代,具体次数取决于通过筛选的模块数量。 - 每次调用都会从初始状态开始。
agent.messages会在每次被清空,因此每个模块都是根据当前手头的笔记来生成的,不会保留之前模块的任何信息,这样每轮处理的内容都相对简洁且独立。 - 所有结果会被合并到一个
draft.md文件中。由于每个模块只进行一次有限次的调用,而非一次性发送“编写课程”的请求,因此9B模型才能完成所有模块的处理。
该指令为通用型,没有针对特定情况的特殊处理。任何阶段都可以使用For each,不过其余三个阶段没有相应指令,会一次性执行完毕。运行mcp-workflow-stage5时,会依次显示[1/5]到[5/5]的状态,示例运行在五个模块中共生成了15,000多个字符。
草稿阶段会逐个模块遍历大纲,每个模块调用一次模型。执行次数取决于大纲中剩余的模块数量,因此有四个或六个模块时就需要进行四次或六次运行。
工作区中还有一个proposed/文件夹,流水线永远不会执行其中的内容。它是一个临时存储区:当某次运行提出一些应长期有效的改进建议,比如更精确的目标设定,或是语音规则编写不断出错时,这些建议会以文件形式保存在那里;如果有人同意,就会将其移至_config/文件夹中。这里人的决策至关重要。在有人手动批准更改之前,工作区中的任何内容都不会改变,这既符合ICM的设计理念,也为后续的自动化处理留下了接口。
哪些有效,哪些无效
9B模型用于编写模块时可以生成可用框架,但无法完成完整的教学内容。其优势在于工作流程及其可解释性,而让本地模型无人值守地编写课程则并非其优势所在。
在实际运行中确实存在一些问题:
- 该草案生成了一个有缺陷的systemd单元文件,其中
[Unit]和[Service]行被合并为同一行,且环境变量被硬塞进了ExecStart字段中。 - 出现了笔记中完全没有提及的版本字符串,同时各模块所使用的模型名称在
gemma2、gemma3和gemma3:2b之间不断变化。 - 由于每个模块都是独立编写且互不知情,模块1和模块2在功能上存在大量重叠,二者都涉及环境变量处理以及systemd相关配置。
- 内容检索速度大约为每页7分钟,因为每个页面都需要经过分块处理后的摘要生成;仅Ollama的常见问题解答就占据了8个分块、共计41,000个字符。
- 其中一个数据来源是Cloudflare的机器人检测屏障。读者正确指出该屏障并无实际内容,因此模型直接跳过而未自行编造内容。
所有这些都不会让该流程变成确定性的处理管道,任何需要此类管道的人都应该自行编写脚本。其优势在于,调整内容都保存在文件中,人们可以在各阶段之间阅读并进行修正,这样初稿就能在相应环节得到修改,避免日后出现意外问题。在文件被编辑之前,“四到六个目标”这一表述仅是对模型的提示,而一旦文件被修改后,它就变成了确定的事实。模型倾向于执行的操作与人们明确规定的要求之间的差距,正是设置各个检查环节的原因。
整体概览
最终,每个流水线都会在代理程序旁边生成一个工作区文件夹,其中包含各阶段的 CONTEXT.md 文件以及用于存储结果的 output/ 文件夹。所使用的代理程序仍是之前的多服务器代理,而驱动它的运行时组件——包括加载器、门控驱动器、工具筛选机制以及模块级循环——都集中在一个约400行的文件中。
总结一下各组成部分:
- 一个工作区,其中各阶段的上下文以编号的纯 Markdown 文件形式存在,可进行编辑而不需修改代码。
- 一个加载器,它从文件中构建每个阶段的系统提示词,将参考资料与实际处理内容分开存放,每阶段的文本长度控制在900到1,500个标记左右。
- 一个带有门控机制的驱动器,它能执行一个阶段、暂停等待用户操作后再继续执行,并将状态保存在磁盘上。
由于工作区仅是一个文件夹,因此不同的任务只需创建不同的文件夹即可。该仓库还包含另一个名为 workspace-rust 的文件夹,用于初学者的 Rust 课程。它复制了第一个工作区的内容,仅在受众画像和一句话概述这两个文件上有所不同,而所有阶段合同、路由文件以及工具使用指南则完全一致。运行 mcp-workflow-stage5 --workspace workspace-rust 即可从同一流程中获得 Rust 课程。这就是 ICM 所说的配置工厂而非产品的理念。
这两个工作空间仅在audience.md内容以及核心理念上有所不同:流程相同,课程不同。
这个框架还有扩展空间。在阶段合约中加入Verify部分,可以让该阶段重新读取之前的输出结果,在人工审核前就标记出偏差,ICM论文中将此称为跨阶段验证。对前面模块的简短总结可以提供给每次草稿生成过程,从而减少模块1与模块2之间的重复内容。而proposed/文件夹则可以变成自动填充内容的区域。
既然已有CrewAI和LangGraph,为何还要手动构建这个轻量版本?它们确实是优秀的工具,而那些需要并行代理、动态分支或实际部署的工作流程才值得使用这么重的工具。然而,许多任务都是顺序进行的、可审核的且可重复的,并不需要这些功能。对于这类任务,每台机器上都已经存在最简单的解决方案:一个文件文件夹。先构建这个版本也能让我们明白,何时才需要使用更复杂的工具。