先别写 Multi-Agent。给 Agent 一个工作目录
窗口是内存。目录才是硬盘。Agent 的第一课不是图画几条边,是四个文件原语。
2026 年 10 月,中文技术时间线被三类 Agent 教程刷满了。
第一类教你画图:reason → tools → respond,再加一个 Supervisor。第二类教你接 MCP,一次挂上十几台 server。第三类盘点框架:LangGraph、CrewAI、AutoGen、谁家的create_agent又改名了。
这三类都能跑通 Demo。它们共同跳过了 Claude Code、Codex、Cursor Agent 真正在跑的那一层:
Agent 先有一个工作目录。图是后来才加上去的。
打开任意一个能连续干三小时活的编码 Agent,它的核心工具不是搜索引擎,不是向量库,是四件事:
ls 看目录 read_file 读文件 write_file 写文件 grep 搜文本记忆在文件里。规则在AGENTS.md里。技能在skills/*/SKILL.md里,启动时只进目录,用到才读正文。大段产出写到out/,回给你的是路径和二十行摘要。
我把这套约定叫做工作目录协议。
协议能用不到 200 行 Python 写完。下面这份代码对着 2026 年 10 月的栈:langgraph 1.2.16、langchain-deepseek 1.1.1、ChatDeepSeek(model="deepseek-v4-pro")。配套实验在lab/workdir-agent/,你可以今天下午抄走。
这篇文章只做一件事:把「目录即运行时」写成一份能跑的教程。图画法、MCP 清单、框架横评,别人已经写过了。
窗口是内存,目录才是硬盘
大模型的上下文窗口看起来像无限硬盘。广告上写 1M token。产线上你每轮真正付得起、模型真正读得清的,通常是前面那一小段稳定内容和后面那一小段新东西。
把窗口当硬盘用,会出现三种很具体的病:
- 每轮重贴。14 个工具的 JSON Schema、两份 MCP、一份 6.8k 的技能说明书,每一轮原样再发一遍。前缀一变,prompt cache 整段作废。
- 产出回灌。Agent 写了 80 行周报,周报又进下一轮 messages。三轮之后,窗口里一半是它自己的作文。
- 技能抢戏。本单只用
csv-report,git-commit的全文也坐在第一排。模型开始认错道具。
工作目录协议把三件事拆开:
| 东西 | 放哪 | 进窗口的时机 |
|---|---|---|
| 硬规则 | AGENTS.md | 每轮,稳定前缀,字节级不变 |
| 技能目录 | skills/*/SKILL.md的 name + 一句 description | 每轮,只进索引 |
| 技能正文 | 同一个SKILL.md的正文 | 模型判断相关之后,read_file一次 |
| 输入数据 | data/ | 按需grep/read_file |
| 产出 | out/ | 写进去。回复里只留路径 |
| 工作笔记 | NOTES.md | 追加。跨会话还在 |
窗口是 RAM:贵、易挥发、有容量上限。目录是磁盘:便宜、可 grep、换模型也不丢。
Claude Code 把项目约定写进CLAUDE.md。Codex 和一众开源 harness 把身份、记忆、技能放进工作区文件夹。它们的差别在文件名。相同的是这件事:Agent 的长期状态,不活在 messages 数组里。
四个文件原语,覆盖 80% 的 Agent 劳动
把工具清单从 14 个砍到 4 个,听起来像功能残废。打开一个能干活的编码 Agent 看它的 trace:80% 的调用就是这四个。
| 原语 | 对应操作系统 | Agent 用它干什么 |
|---|---|---|
ls | 列目录 | 先看工作区长什么样,禁止盲写 |
read_file | 读文件 | 分段读,带行号,大文件禁止一次灌进回复 |
write_file | 写文件 | 产出落盘。父目录必须已存在 |
grep | 搜文本 | 从 csv / 日志 / 规则里定位,不靠模型口算 |
这四个工具有一条共同的硬约束:路径锁在 workspace 里。相对路径解析之后,必须以工作目录为前缀。少了这一行,read_file("../../.env")会变成你自己给自己开的后门。
ROOT=Path(__file__).resolve().parent/"workspace"defrel(p:str)->Path:target=(ROOT/p).resolve()ifnotstr(target).startswith(str(ROOT.resolve())+os.sep)andtarget!=ROOT.resolve():raiseValueError(f"path escapes workspace:{p}")returntarget其余工具——退款、开票、发券、搜网页——全部降级成技能文件里的说明书,或者降级成「需要时再bind的短名单」。它们不再默认出现在每一轮的 schema 里。
四个原语是操作系统。业务工具是应用程序。应用程序按需加载。操作系统每轮都在。
一份最小可运行的工作区长这样:
workspace/ AGENTS.md # 硬规则,稳定前缀 TASK.md # 本周任务 NOTES.md # 工作笔记,Agent 追加 skills/ csv-report/SKILL.md # 用到才读正文 git-commit/SKILL.md # 本任务应跳过 data/orders.csv # 只读输入 out/ # 产出AGENTS.md建议压在 80 行以内。写具体可验证的句子:「数字必须来自文件」「产出写到 out/」「禁止一次读完所有 skills」。形容词留给产品文案。
稳定前缀:本会话里不许改的那一段
Prompt cache 的规则很朴素:从左到右,字节一变,后面整段作废。
所以工作目录协议规定,发给模型的消息分两截:
- 稳定前缀。
AGENTS.md全文 + skill index(名字和一句描述)+ 四个工具的 schema。本会话内字节级不变。工具按名字排序,不按「这一轮可能用得上」动态增删。 - 可变后缀。用户任务、工具返回、Agent 自己的短回复。只在这里生长。
配套脚本prefix_stats.py不调模型,只称重量。实验仓这一份的数字是:
skill index 44 tok stable prefix 228 tok if dump csv+skills 494 tok实验仓很小,差别看起来不大。把它放大到产线常见规模,账会很难看:
| 做法 | 每轮固定前缀 | cache |
|---|---|---|
bind_tools(ALL_TOOLS),14 个业务工具 + 两台 MCP | 约 6.8k schema,再加技能全文 | 前缀每轮因工具名单微调而抖动 |
| 工作目录协议:4 个原语 + skill index | 约 0.2k~1k | 前缀字节级稳定,命中便宜输入 |
DeepSeek V4 Pro 对 cache hit 的输入计价,显著低于 cache miss。你把工具名单写成「按意图动态 bind」,等于每一轮主动把折扣关掉。
稳定前缀的工程要点就三条:
- System 消息在循环外构造一次,循环内原样复用。不要
f"当前时间 {datetime.now()}"塞进 system。时间放进 Human 消息。 - 工具 schema 排序固定。
TOOLS = [grep, ls, read_file, write_file]和[ls, read_file, write_file, grep]对模型差不多,对 cache 是两份前缀。 - 限制工具用
tool_choice/ 运行时拦截,不要删 schema。删一个工具,前缀就变了。
技能是文件,启动时只进目录
Skill 不是 system prompt 里的一章长文。它是一个文件夹:
skills/csv-report/SKILL.md文件头两行给索引用:
---name:csv-reportdescription:把 csv 做成带数字来源的 Markdown 周报---启动时,稳定前缀只吃到:
- csv-report: 把 csv 做成带数字来源的 Markdown 周报 [skills/csv-report/SKILL.md] - git-commit: 按仓库约定写提交说明,不在本任务使用 [skills/git-commit/SKILL.md]模型看见任务是「做退款风险周报」,自己read_file("skills/csv-report/SKILL.md")。git-commit的正文整场都不会进窗口。
这就是渐进披露。十个技能在启动时的成本,约等于十行描述。用到第三个,才付第三份正文的 token。
description决定模型会不会点开。写成「处理文件」等于没写。写成「把 csv 做成带数字来源的 Markdown 周报」才是可路由的。
技能正文里写步骤,写验收,写「不要把整份 csv 贴进对用户的回复」。步骤比形容词有用。
不到 200 行把协议跑起来
实验仓入口是lab/workdir-agent/agent.py。核心就这些。
四个工具。路径全部走rel()。read_file带行号,默认 200 行,文件超过 80KB 截断。write_file拒绝创建不存在的父目录——逼 Agent 先ls。
@tooldefls(path:str=".")->str:"""列出工作目录下某个相对路径。默认列出根目录。"""...@tooldefread_file(path:str,offset:int=1,limit:int=200)->str:"""按行读取工作目录里的文本文件。offset 从 1 起。"""...@tooldefwrite_file(path:str,content:str)->str:"""把文本写入工作目录。父目录必须已存在。"""...@tooldefgrep(pattern:str,path:str=".",glob:str="*")->str:"""在工作目录里按正则搜文本。最多 50 条。"""...TOOLS=[ls,read_file,write_file,grep]图仍然是教科书结构。变的是工具的数量,和 system 从哪来。
defstable_prefix()->str:agents=(ROOT/"AGENTS.md").read_text(encoding="utf-8")return(agents+"\n\n## skill index\n"+skill_index()+"\n\n工具只有四个:ls / read_file / write_file / grep。""它们的 schema 本会话内不会变。")defreason(state:AgentState):sys=SystemMessage(content=stable_prefix())resp=make_model().invoke([sys,*state["messages"]])return{"messages":[resp]}stable_prefix()读磁盘,不读 messages。AGENTS.md你改了,下一轮生效;模型改不了自己的前缀。这是协议故意留的单向门。
模型用ChatDeepSeek:
model=ChatDeepSeek(model="deepseek-v4-pro",temperature=0,timeout=120,)model=model.bind_tools(TOOLS)thinking 开不开,随你。开了之后,推理进reasoning_content,条件边仍然只认tool_calls。四个原语的 docstring 写清楚「何时用」,比把推理开到 max 更管用。
本地跑:
cdlab/workdir-agent python3-mvenv .venv&&source.venv/bin/activate pipinstall-rrequirements.txt python prefix_stats.py# 不调模型exportDEEPSEEK_API_KEY=... python agent.py一次真实任务:csv 变成周报
TASK.md写得很短:
把 data/orders.csv 做成退款风险周报。 输出:out/weekly.md 格式:按 skills/csv-report/SKILL.md 范围:status=paid 且 created 在 2026-10-13 至 2026-10-19(含) 风险:同一 uid 窗口内支付 >= 3 笔,或单笔 amount_cents >= 50000 金额在 csv 里是分,展示写成元csv 只有 10 行,是故意的。任务的难点不在数据量,在协议有没有被遵守:
- 先
ls,看见data/skills/out/。 read_file("TASK.md"),再read_file("skills/csv-report/SKILL.md")。git-commit的正文不读。grep/read_file拿数字。金额用文件里的分换算,禁止口算凭感觉。write_file("out/weekly.md", ...)。回复里只给路径和摘要。NOTES.md追加不超过 5 行决策。TASK.md标 done。
跑完之后,磁盘上的事实是:
out/weekly.md 支付 7 笔 / 1507.00 元 / 风险 uid:u12、u9 NOTES.md 三条短决策 TASK.md 状态:done窗口里没有周报全文。下一轮任务从ls开始,周报还在out/。换一个模型接着干,它读得到同一份文件。
周报正文长这样。每个数字能指回文件:
把这份周报和「模型在对话框里直接回一篇 Markdown」并排放。后者漂亮,前者可归档、可 diff、可给下一个 Agent 当输入。工作目录协议买到的是第二件。
接到你现在的图上,改五处
你大概已经有一张reason / tools / respond的图。不必推倒。改五处。
1. 工具名单改成四个原语
bind_tools(ALL_TOOLS)改成bind_tools([ls, read_file, write_file, grep])。退款、开票、发券从 schema 里撤走,写进skills/*/SKILL.md。真的要打网关时,再在技能正文里规定「调用内部 HTTP 客户端」——那是下一层,不是默认前缀。
2. System 改成读AGENTS.md
把五十行角色扮演从代码字符串里搬进文件。代码只负责Path.read_text。产品改规则走 PR,不走重新发版的 prompt 常量。
3. 技能进索引,不进正文
启动扫描skills/*/SKILL.md的 YAML 头。每轮只贴 name + description + 路径。模型用read_file点开。
4. 产出写文件
约定:超过 20 行的产出必须write_file到out/。Tool 返回值只留wrote out/weekly.md 612B。窗口里不再堆作文。
5. 前缀称重
加一个prefix_stats.py。CI 里断言稳定前缀不超过 N token(先定 1500,再按模型账单收紧)。有人往AGENTS.md贴了一章架构说明书,构建就红。
五处改完,图还是那张图。变的是:窗口里住着操作系统,应用程序住在目录里。
验收:对着工作目录问七句
协议有没有生效,不看 LangSmith 是不是绿。看磁盘。
- 启动后第一轮工具调用,是不是
ls?盲写out/foo.md算失败。 - 本任务用不到的
SKILL.md,全文有没有进过 messages?进过算失败。 - 稳定前缀的字节,第 1 轮和第 8 轮是否完全相同?不同算失败。
- 用户可见回复是否短于 20 行,并且带
out/路径?把全文贴进对话框算失败。 out/里的数字,能否用grep在data/里对上?对不上算失败。- 换一个
thread_id再开一轮,只给「继续上周任务」,Agent 能否靠ls+read_file接上?接不上,说明记忆还住在窗口里。 read_file("../.env")是否抛错?不抛错,先别接业务。
这七句可以写成 pytest。fixture 起一份临时 workspace,断言文件,不断言模型文采。
你今天下午就能抄走的最小集
lab/workdir-agent/ agent.py # 不到 200 行:图 + 四个原语 prefix_stats.py # 称重量,不调模型 requirements.txt # langgraph 1.2.16 · langchain-deepseek 1.1.1 workspace/ AGENTS.md TASK.md NOTES.md skills/csv-report/SKILL.md skills/git-commit/SKILL.md data/orders.csv out/抄走之后只改三份文件:AGENTS.md换成你的硬规则,skills/换成你的两三个真技能,TASK.md换成你下午那件事。工具代码可以暂时不动。
模型用deepseek-v4-pro或你现在的主力。协议和模型解耦。换模型是换ChatDeepSeek那一行。换协议是换目录约定。
工作目录协议是 Agent 开发的第一课。图、MCP、Multi-Agent 是第二课、第三课。第一课没上,后面每一课都在往一个没有硬盘的进程里堆内存。
四个原语,一份AGENTS.md,技能按需加载,产出落盘。这套东西 2026 年每一个能连续干活的编码 Agent 都在用,只是没人把它写成可以抄的协议。
评论区请贴两样东西:你现在的 Agent 启动时bind_tools的数量,以及AGENTS.md(或等价文件)的行数。只贴这两个数字。