本文记录我开发的一个轻量 AI Coding Agent - Nanocode 的开发笔记.
项目地址: https://github.com/hit9/nanocode, 欢迎 star!

模型切换 ¶
nanocode 理论上可以支持所有 OpenAI Chat-Completion 风格的 API Providers. 我测试过的有 deepseek, aliyun, opencode-go, zenmux llama.cpp, 测试下来的体验:
- GPT-5.5 可以说一骑绝尘, “操刀精准”.
- Deepseek-v4-flash 性价比很高.
- 本地量化模型中 qwen3.6-35b-a3b 和 qwen3.6-27b 表现出色. 不过, 我认为目前的本地量化模型并不适合 AI Coding 场景.
nanocode 中可以非常方便的切换 providers, models, reason-efforts:

Agent Loop ¶
ReAct 和 闭环控制 ¶
开发 nanocode 的最神奇的体验是, 当它 Loop 起来的时候, 平时用起来很笨的小模型好像有了智能了.
没有 Loop 的 LLM 调用, 不叫 Agent.
nanocode 的 Loop 非常简单, 也就是最简单的 ReAct Pattern:
观察 -> 思考 -> 工具调用 -> 观察 -> 思考 -> 工具调用 …
我们利用 nanocode 来解读一下它自己的代码

这非常像我之前写过的 “闭环控制” 的概念, 也就是 基于反馈不断调整. 我经常把这种过程比喻成 “倒车入库”, 一次性倒车入库的难度大, 但是多观察着调整几把, 就能更稳的做到了.
把用户发起输入, 到 LOOP 结束叫做一个 turn 的话, 那么一个 turn 结束的条件是:
- LLM 不再调用工具.
或: 超过了设定的 Max-Steps 阈值:
这是必要的设计, 因为我们无法严格保证 LOOP 会最终结束. 给 LLM 加上一些 “记忆” 支持, 它可以把试过的 “死胡同” 记下来则可以有效避免死循环, 但无法 100% 保证.
这种循环模式中, 关键点就是两个: “反馈” + “调整”:
- LLM 要有机会可以知道环境现状, 出过错.
- LLM 要有机会可以对历史 Work 进行进一步调整修正, 最终收敛到目标.
可收敛 ¶
曾观察过一个只有 GPT5.5 可以拿下的 BUG 的调查过程, 其收敛过程类似于 图上深度优先遍历. 它会沿着当前假设批量的收集证据,拿到充分的上下文后,才下手改动.
首先我们在 “黑板” 上写一个问题 (目标), LLM 提出 N 种假设, 接下来深度优先地测试各个假设:
- 如果验证, 则达成目标
- 如果证伪, 则黑板上划掉这个假设, 继续下一个.
- 在此期间, LLM 不断调查环境收集证据, 也可以调整假设列表.
从控制论的角度看, 可能性空间必须不断收缩, 才是有效的控制过程. 也就是说, Agent LOOP 过程必须是 “可收敛的” , 因此一定要有一个可以记录和追踪的地方, 这个就是 上下文 (Context).
工具设计 ¶
工具列表 ¶
目前支持的完整工具列表:

我认为, 设计好一个 Tool 的要义是: 要从 LLM 的视角来看, 它是否足够清晰明确:
- 保持简单: 复杂的工具设计更容易造成调用失败和理解偏差.
- 无歧义: 清晰、简洁、一致的调用姿势.
- 单一职责: 最好一个工具干一个事,工具之间能力无交叉.
- Examples: 典型的使用场景, 给出调用示例.
- 明确 Return: 一定要告诉 LLM 它使用了这个 Tool 后将会得到什么.
- 区分 Prompt 和 Tool Description: 核心是单一职责:
- 描述 Tool 自身的, 放在 Tool Description 里.
- 不同工具的选用偏好则放在 Prompt 中.
批量调用 ¶
我倾向于赋予工具批量调用的能力,尤其是探索阶段,有利于加速收敛.
比如说:
Search可以一次性搜索多个 pattern:Search A|B|CRead可以一次性读取多个 range:Read code1.py 1:100 code2.py 200:300Edit也可以一次性操作多个点位.Recall也可以一次性召回多条结果:Recall tr.1 tr.2
一次 LLM 调用中 (叫做一个 batch) 内也可以多个 ToolCall 来做到批量调用.
自动确认机制和拒绝 ¶
每次 ToolCall 都让用户确认的话, 会很麻烦. 但是不确认的话, 又怕脱离用户预期.
nanocode 在非 yolo 模式下的自动确认机制很简单: 明确是读当前目录的 ToolCall 会免确认, 否则请求用户确认.

我还做了一些改善设计:
- 快速失败: 入参错误则免确认直接失败.
- 快速跳过: 同一个 batch 内, 前面的调用被拒绝, 则后面的自动跳过.
带反馈的拒绝: 用户可以在拒绝一个 ToolCall 的时候说明原因

拒绝一个 ToolCall 的时候可选择说明原因 
拒绝后的原因会被 LLM 看到
容错设计 ¶
即使工具签名十分清晰,LLM 仍可能调错. 工具必须考虑好容错 (兼容意外), 但是不必展示给 LLM 这种意外情况。 我们只告诉 LLM 最期待的调用方式. 简而言之, 提供期待, 兜住意外.
举例 - ReadTool 的参数兼容设计
ReadTool 签名上期待 LLM 传数组形式的 ranges :
"ranges": [[0, 80], [120, 180]]
但是仍然兼容 LLM 传了下面 “忘记包一层” 的单组形式的 range:
"ranges": [0, 80]
而 ReadTool 的签名设计是这样的, 并不会暴露给 LLM 这种兼容设计:
Read(path, ranges=[[start, end],...])
Read(files=[{path, ranges}])
错误反馈 ¶
一旦 LLM 调用方式出错, 而毫不知情的话,很可能继续尝试下去, 也就是可能死循环调工具.
我做了个简单的设计,上下文中存在一个错误反馈区域, 会把最近 5 条 ToolCall 错误消息放在那里, 在当前 turn 内持续存在, 以提醒 LLM “犯过的错误不要再犯”.
每条错误消息的设计上,尽量做到两点:
- 哪里错了: 一般就是错误消息,比如说 “Bash requires exactly one argument”
- 怎么做才是正确的: 明确告诉正确的做法是什么, 比如说 “try Bash(command)”
有界输出和召回 ¶
工具结果可能会很长, 必须要做 “Bounded Output” 设计.
不可依赖 LLM 来帮助裁剪工具输出
某些情况下 LLM 会有意识地避免过大输出, 比如说:
pytest -v 2>&1 | tail -n 10
但是,程序不可依赖这一行为,这种 approach 有 3 个问题:
- 依赖模型智力 和 稳定性.
- 分散模型注意力.
- 总有失败的那一次, 但是爆上下文是硬性的 breaking 问题.
我的设计比较简单,要点如下:
- 超限结果做摘要: 超过长度的结果会被程序摘要(裁剪),这个会放在上下文中.
原始结果可召回:必须保留模型可以查看原文结果的能力.
每次工具调用都会被标号,例如
tr.27.有一个专用的
Recall工具来帮助 LLM 回看历史 ToolCall 结果.
nanocode 它自己调用 RecallTool 做的演示 结果原文太大也没关系, LLM 可以按 Range 来召回.
文件读写 ¶
为了更安全的编辑,Read 会返回每行内容的 hash 指纹,叫做 anchor. 只有 行号 + anchor 都对齐了,才会编辑成功.
Read 的结果的样例如下:
<Read path="nanocode.py">
<file_stat mtime_ns="1780653186224374398" size="222039"/>
<total_lines>5031</total_lines>
<range>684:760</range>
<content hashline-numbered>
684:234ew|class Tool:
685:7xy0d| NAME: ClassVar[str] = ""
686:5exvk| DESCRIPTION: ClassVar[str] = ""
...
</content>
</Read>
可以看到:
- 采用了 XML-like 的形式组装,因为要考虑到代码缩进、换行,这比 JSON 更适合.
- 左边是
line:anchor信息,中间有条纵向对齐的分割线|,右边则是代码. 从 LLM 的视野来看,会非常清晰.
Edit 的签名如下,支持的 OP 完整覆盖了 创建、替换、删除、批量替换 等场景:
Edit({
path: string, ← 目标文件
edits: [{ ← 编辑操作数组
op string, ← create | replace | delete |insert_before | insert_after | replace_all
start: string? ← 行锚点 "line:hash" 格式
end: string? ← 行锚点 "line:hash" 格式
content: string? ← 新内容
old: string? ← replace_all 要替换的旧文本
new: string? ← replace_all 的新文本
}]
})
这里面最复杂的场景是 并发编辑。 无论是一个 batch 内的多个 Edit 调用, 还是一次 Edit 调用中的多个编辑点, 都会有一个棘手的问题:当前面的编辑 apply 之后,后面的 anchor 和 行号 就会失效,这之间存在前后依赖关系.
解决思路是:先在内存里把所有编辑预演一遍,确认没问题后再真正写文件:
- 在内存里维护一份临时文件状态
- 临时文件里的每一行除了有内容,还会记录一个
origin,表示它来自原始文件的第几行。 - 这样即使前面的 Edit 导致行号漂移,后面的 Edit 仍然可以通过
origin找回真正要修改的那一行。
举个批量编辑的例子 (推演过程)
原始文件是:
0: a
1: b
2: c
现在同一个 batch 里有两个 Edit:
- Edit 1: 在
1:hash(b)前插入x - Edit 2: 把
2:hash(c)这一行替换成C
原始状态:
+------+------+
| 行号 | 内容 |
+------+------+
| 0 | a |
| 1 | b | <- Edit 1: 在这里前面插入 x
| 2 | c | <- Edit 2: 想把这里改成 C
+------+------+
执行 Edit 1 后, 内存里的临时文件变成:
+------+------+- --------+
| 行号 | 内容 | origin |
+------+------+- --------+
| 0 | a | 0 |
| 1 | x | N/A |
| 2 | b | 1 |
| 3 | c | 2 |
+------+------+- --------+
这时 Edit 2 还拿着旧 anchor: 2:hash(c)。
nanocode 会先看当前第 2 行:
当前第 2 行 = b
=> b != c
=> 不匹配
然后它再看 batch plan 里保存的原始第 2 行:
原始第 2 行 = c
=> hash 匹配
=> 说明 Edit 2 要改的是“原来的第 2 行”
接着 nanocode 在当前临时文件里查找 origin = 2 的行:
origin = 2 -> 当前第 3 行 c
所以 Edit 2 最终会被定位到当前第 3 行:
最终结果:
0: a
1: x
2: b
3: C <- Edit 2 真正修改的位置
一句话说: anchor 负责确认“要改的是哪一行”, origin 负责在行号漂移后把这行找回来。
符号索引 ¶
最开始用过 codegraph 和 cymbal, 类似的工具还有一些,都是基于 tree-sitter 对 codebase 建立符号索引, 这样可以加速 coding agent 探索代码的过程,比如说寻找 Impls,References 等会更精准、更快。
后来我干脆直接 AI Coding 了一个,就是它:
https://github.com/hit9/code-symbol-index
而且,它还可以支持带 anchor 的输出,完美配合 nanocode.
这个索引已经内置到了 nanocode 中, 也就是 InspectCode 这个工具, 包含三种能力:
find: 搜索一个符号, 返回定义的位置.InspectCode - find 样例输出
InspectCode {"mode":"find","target":"Edit","limit":8} symbols: - class Edit nanocode.py:1529:1536 - class EditTool nanocode.py:1538:1756 - class EditBatchPlan nanocode.py:2549:2729 - def edit_barrier nanocode.py:2771:2785inspect: 看一个符号, 返回带编辑锚点的源码,成员,引用,实现方.InspectCode - inspect 样例输出
InspectCode {"mode":"inspect","target":"EditTool"} symbol: class EditTool(Tool) nanocode.py:1538:1756 summary: members: 14 references: 33 members: - call - preview - parse - build - apply - resolve_anchor references: - nanocode.py:2113 - tests/test_tools.py:216 - tests/test_tools.py:217outline: 看一个文件或一个符号内的结构树InspectCode - outline 样例输出
InspectCode {"mode":"outline","target":"nanocode.py","symbol":"EditTool"} 1538:1756 | class EditTool(Tool): 1553:1566 | def params_schema(...) 1571:1588 | def call(...) 1589:1594 | def preview(...) 1610:1645 | def parse(...) 1646:1664 | def build(...) 1665:1712 | def apply(...) 1743:1756 | def resolve_anchor(...)
nanocode 会自动维护索引更新:
- 启动后自动异步后台更新 (status bar 可见).
- nanocode 在编辑文件之后, 还会自动维护这些文件的索引(增量更新).
code-symbol-index 还可以安装成为一个 codex skill, 我的 codex 基本都会优先走索引,比它内置的 Search 出镜率要高,而且 codex 也懂得在修改文件后增量维护索引。
这其实比支持 LSP 要更轻量,更适合 “nano” 的定位.
上下文设计 ¶
整个开发过程中,我体会最深的就是:上下文设计是关键。 这也是花功夫最多的地方.
Prompt 缓存 ¶
Prompt Cache 本质上是跨请求复用 KV Cache。被缓存命中的 token 价格更低,延迟也可能更低。 它通常依赖 “前缀匹配”:后一次请求的输入前缀,和前一次请求的输入前缀一致,才有机会命中缓存。
对于 LOOP 方式的 LLM 调用,Prompt 缓存就是很关键的优化了. 从底层看,KV Cache 当然是 token-level 的。 但实际使用时,是否命中不只取决于文本是否相同,还取决于 Provider 在协议层怎么处理 messages。
我一开始把上下文组装成两条消息:
[system: 固定系统提示]
[user: 环境信息 + 会话历史 + 文件状态 + 当前请求]
这样做的好处是控制简单。我也尽量把稳定内容放前面,把变化内容放后面。但实际测试下来,缓存率并不理想。
后来我改回多条 messages:
[system: 固定系统提示]
[user: 环境信息]
[user/assistant/tool: 会话历史], [ ... ], [ ... ], ...
[user: 当前请求]
[user: Memory]
[user: File State]
同样是稳定前缀,但缓存率明显上去了。

所以这里的经验是:Prompt Cache 不只是“文本前缀稳定”这么简单。 对接不同 Provider 时,messages 的边界、角色和顺序,也可能影响缓存命中。
上下文布局 ¶
上下文布局如下图所示:
role: content
┌──────────────────────────────┐
│ system: SYSTEM_PROMPT │
├──────────────────────────────┤
│ user: Environment [fake] │ ← nanocode 插入
├──────────────────────────────┤
│ user: old request │
│ assistant: old answer │ ← history turns +
│ tool: old tool result │ |
│ ... │ |
├──────────────────────────────┤ | ← chat messages
│ user: current request │ |
│ assistant/tool/... │ ← current turn +
├──────────────────────────────┤
│ user: Memory [fake] │ ← nanocode 追加
├──────────────────────────────┤
│ user: FILE STATE [fake] │ ← nanocode 追加 (文件视图)
└──────────────────────────────┘
总体分为 5 个部分:
SYSTEM_PROMPT: 系统提示词,无变量、最稳定.Environment:不是真实的聊天历史,当前环境信息,很稳定.Environment 消息内容示例
--- Environment --- - cwd: /Users/hit9/dev/github/nanocode - os: Darwin - arch: arm64 - shell_timeout: 30s - detected_commands: bash, git, rg, python3, pytestchat messages:聊天历史,包括之前轮次的消息 和 不断增长的当前轮次的消息.角色包括
user,assistant,tool. 每次 ToolCall 都会诞生一条新的消息 Append 进去,这个消息上会体现 executed 的调用方式 和 结果:聊天历史 role=tool 的消息示例
tool tr.39 Search "raise ToolError" path=nanocode.py <SearchToolResult pattern="raise ToolError" matches=96> > nanocode.py:726:37blw| raise ToolError(...) ... </SearchToolResult>Memory:非真实聊天历史. 这里主要包括 High-Level 的 “记忆”, 也就是 LLM 的记事本.File State: LLM 历史读写过的文件的横切面视图,下一个部分会细讲.
从整个布局来看,在当前 turn 最后一条消息之前的部分,都可以利用缓存。
Memory ¶
nanocode 的 “记忆” 部分非常简单,没有持久化存储,也没有检索,没有向量。 它就是很简单的一块内存 “黑板”.
有一个记事本的工具 Note,专门给 LLM 来 “打草稿”:
Note(goal?, plan?, known?, check?)
这里 Memory 部分的视图如下,除了日期之外,只包含最简单的 目标、计划、已知、检查.
┌─ user: Memory [fake] ───────────────┐
│ --- Memory --- │
│ │
│ Goal: 当前任务目标 │
│ │
│ Plan: │
│ - 当前计划 │
│ - 后续步骤 │
│ │
│ Known: │
│ - 已确认的事实 │
│ - 用户约束 │
│ │
│ Check: 已执行/待说明的检查 │
│ │
│ Date: 2026-06-07 │
└─────────────────────────────────────┘
值得说明,这里 没有状态机建模,没有转移规则,没有 Guards/Gates. 也就是不会强制规定任务必须处在哪个阶段。
编码任务往往是探索式的:阅读、搜索、修改、验证会反复交错的。 因为写代码不是固定流程。很多时候不是:
plan -> edit -> test -> done
而是:
读一点 -> 改一点 -> 发现新事实 -> 调整计划 -> 再读 -> 再验证
比如说,假设我们强制 LLM 必须要先设定 Goal,再进行 Plan,再按照计划进行,做完目标前必须要 Check 等. 这似乎看上去 “很合理”,但是有问题的(意外情况还有很多,这里列举两个):
- LLM 不进行充分的调查探索,也做不出来真正切合实际的目标
- 有的简单任务确实也不需要走一遍状态流转,不需要验证
这里的设计更像一个纯工作白板,不规定 LLM 下一步必须做什么,而把自主决策充分交给 LLM。 这也是足够泛化的做法。
文件视图 ¶
除了 Read 和 Edit 这两个工具,其他工具的结果分两层记录:
- 作为历史聊天消息放在上下文中 (超界时摘录).
- 存在内存中,可召回. (直到无消息再引用它时永久消失).
那么,对于文件读写,LLM 更合适的阅读视角是什么?我的思路是: 不要让模型从历史 tool output 里推断文件状态,而是直接给它一个“当前文件视图”。
Read 和 Edit 的结果会被抽取出来,汇总到尾部的 FILE STATE 中。它不是一段历史记录,而是当前仍然有效的文件片段。
大概是这样:
块 作用
━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
Current focus 当前关注点
──────────────────── ─────────────────────
Files 有效文件范围
──────────────────── ─────────────────────
Recent file events 最近 Read/Edit 操作
──────────────────── ─────────────────────
Recent code edits 最近代码文件修改
──────────────────── ─────────────────────
Check status 验证状态
──────────────────── ─────────────────────
Recent tool errors 最近错误
──────────────────── ─────────────────────
Content 当前内容 + 编辑锚点
──────────────────── ─────────────────────
Omitted content 过期/省略内容
实际的样例数据在上下文中长这样:
--- FILE STATE ---
Read/Edit outputs update this section.
Treat listed ranges as current file state.
Current focus: patch EditTool anchor validation
Files:
- nanocode.py 1538:1560 current
Recent file events:
- tr.12 Read nanocode.py 1538:1560
- tr.13 Edit nanocode.py
Recent code edits:
- tr.13 Edit nanocode.py
Check status:
- Code changed recently. Use Note(check=...) after checks, or final must say checks not run.
Content:
Format: line:hash|text. Use line:hash as edit anchors.
@@ nanocode.py 1538:1560 current source=tr.13 tool=Edit
1538:abcde|class EditTool(Tool):
1539:f0123| NAME = "Edit"
1540:98765| DESCRIPTION = "Create or patch one UTF-8 file..."
你可以看到,LLM 在这里可以看到所有调查过的文件的代码现状. nanocode 会在 range 级别用新的读取结果来覆盖旧的读取结果, 并在附近用 @@ 标记当时的操作文件的行号范围。 并且,这里的展示仍然是带着 line:anchor 的. 这里 current 明确告诉 LLM 这里给你看的文件内容就是最新的,拿着这里的 line:anchor 去编辑毫无问题.
Recent file events 和 Recent code edits 的区别
前者是最近的 Read 和 Edit 操作流水,后者只筛出最近的 代码 文件修改,用来提醒模型需要做检查。
而历史消息中, Read 和 Edit 的消息会折叠成很简单的一行到文件视图的 “指针” 记录:
tool tr.12 Read nanocode.py -> FILE STATE
tool tr.13 Edit nanocode.py -> FILE STATE
这个文件视图不是一份单独维护的状态,而是每次请求前现算出来的。 nanocode 会从历史 Read/Edit 记录中抽取文件片段,再根据当前文件状态判断哪些片段仍然有效。 只有仍然匹配当前文件的内容,才会进入 FILE STATE。
为了避免模型看到旧代码,nanocode 不会无条件相信过去的 Read/Edit 结果。
past Read/Edit ── validate ── current ──> FILE STATE
│
└── stale ──────> omitted
它会做两层确认:
- 先看文件有没有变过。没变过,之前读到的片段就还能用。
- 如果文件变过,就再看这些行现在是不是还长一样。对不上,就不再展示。
所以旧的 Read/Edit 记录不会自动进入 FILE STATE。只有确认仍然有效的代码片段,才会出现在这个视图里。 比如说,如果用户在 nanocode 改代码期间也改了这个文件,那么文件视图会丢弃无法确认仍然有效的片段,LLM 则不得不重新 Read 来获取最新的代码现状了。
文件视图应该是 nanocode 中最有特色的一个设计了.
上下文压缩 ¶
上下文压缩最原始的思路很简单:把多条旧聊天历史,收纳成一条总结性的 Prior Summary 消息。 它不是把所有东西都 “总结一下”,而是只处理当前 turn 之前可压缩的旧聊天历史。
这个机制很简单:
- 旧历史:压缩成一条
Prior Summary. - 最近消息:原样保留.
- 当前 turn:不触碰.
Memory/FILE STATE:照常在请求前追加.
也就是说,nanocode 不会在一个正在进行的 turn 中间,把当前用户请求、刚刚发生的 ToolCall、刚得到的工具反馈拿去改写。 这些消息对下一步判断最敏感,所以保持原样。
压缩前后大概是这样:
压缩前 压缩后
┌─────────────────────────┐ ┌─────────────────────────┐
│ system: SYSTEM_PROMPT │ │ system: SYSTEM_PROMPT │
├─────────────────────────┤ ├─────────────────────────┤
│ user: Environment │ │ user: Environment │
├─────────────────────────┤ ├─────────────────────────┤
│ user: old request │ │ user: Prior Summary │ ← 旧消息摘要
│ assistant: old answer │ ├─────────────────────────┤
│ tool: old result │ │ user: latest request │ ← 最近消息保留
│ user: latest request │ ├─────────────────────────┤
├─────────────────────────┤ │ user: current request │ ← current turn 原样
│ user: current request │ │ assistant/tool/... │
│ assistant/tool/... │ ├─────────────────────────┤
├─────────────────────────┤ │ user: Memory │
│ user: Memory │ ├─────────────────────────┤
├─────────────────────────┤ │ user: FILE STATE │
│ user: FILE STATE │ └─────────────────────────┘
└─────────────────────────┘
这里有一个重要点:压缩器只处理当前 turn 之前可压缩的旧聊天历史。
当前这一轮正在发生的消息不会被改写;Memory 和 FILE STATE 也不会被塞进摘要里。 它们仍然作为独立区域,在每次请求前重新放到上下文尾部。 所以压缩器不需要把文件内容、编辑锚点、工具大输出都塞进摘要里。 它只需要保留继续任务所需的背景,比如用户意图、关键决定、已经排除的方向、还要注意的约束。
压缩之后,旧的 tool 消息可能已经被摘要替代,但内存里的工具结果不会简单清空。 nanocode 会保留仍被 tr.N 引用的结果,以及当前 FILE STATE 还依赖的 Read/Edit 结果。 其他已经没有引用价值的旧工具结果,才会被裁掉。
这也意味着 FILE STATE 本身不需要单独压缩。 上下文压缩剪掉旧 tool result 后,下一次请求前重新计算文件视图,它会自然变小;仍然有效、仍被文件视图依赖的 Read/Edit 结果则会被保留。
这样压缩后的上下文不会变成一坨含糊的总结,而是仍然保持几个清晰区域:
- 旧对话 → 变短.
- 最近消息 → 保真.
- 当前 turn → 不动.
- Memory → 继续提供任务状态.
- FILE STATE → 继续提供当前文件视图.
自主性 ¶
反馈约束 ¶
我倾向于尽量保持 LLM 的自主决策能力。
Agent 不是一个普通的 workflow。workflow 的核心是程序决定下一步,而 Agent 的核心是模型根据上下文判断下一步。
所以 nanocode 很少做强流程门禁。比如,不会规定必须先 Plan 才能 Read,必须 Read 几次才能 Edit,必须 Test 才能 Final。 编码任务的路径变化太大,过多 gate 很容易把模型框死。
nanocode 更倾向于用反馈约束,而不是流程约束:
- 工具调用错了会失败.
- 编辑锚点过期会失败.
- 文件状态会提示最近改动.
- 检查状态会提醒模型最终说明是否验证过.
这样程序不替模型决策,但会持续提供环境反馈,让模型自己调整路线。
这也是为了泛化。很多看似合理的规则,实际上只对某类任务成立。 一旦把这些规则写死,就会让 agent 在其他任务上变笨。 相比设计复杂流程,我更愿意把工具、上下文和反馈机制做好,然后把具体路径交给模型。
Prompt ¶
同样的思路也体现在 Prompt 上:Prompt 要尽量短。
Prompt 本质上是一组人工写下的规则。规则越多,看起来越安全,但也越容易把模型限制在某些特定场景里。 很多 prompt 规则其实是在修补某一次失败案例,一旦不断把这些 case 写进去,最后就会变成一份很长、很脆、很难泛化的行为清单。
所以 nanocode 的 system prompt 只保留几类最稳定的约束:
TOOLS 可用工具,以及工具调用方式
FLOW 基本行动原则
CONTEXT 如何相信 FILE STATE / Recall / Note
EDITS 编辑前后的安全约束
FINAL 最终回答格式
它不会详细规定每一种任务应该怎么走,也不会把常见 workflow 写死。 Prompt 只负责告诉模型边界和偏好;具体怎么调查、怎么修改、什么时候验证,仍然交给模型根据上下文自己判断。
总而言之,还是那两个字:泛化.
一些放弃了的尝试 ¶
开发过程中也试过一些方向,最后都删掉了。
主要原因基本相同:局部看合理,整体会增加复杂度。
分离 SubAgent
把 explore、verify 交给小模型,理论上可以降低主模型噪音和成本;实际同步成本太高。
放弃原因主要有几个:
- 上下文变成两层:主 Agent 看全局,子 Agent 看局部,设计复杂度明显上升。
- 反馈环变长:委派、汇报、再理解、再决策,中间会丢细节,主 Agent 经常还要重新确认。
- 委派颗粒度不好控制:主 Agent 容易把一个过大的任务直接丢给子 Agent。
- 汇报粒度难校准:太长会把噪音带回来,太短又不足以支撑判断。
SubAgent 方向本身不一定错,但它需要更细腻的上下文分层和任务拆解能力,和 nanocode 的轻量定位不匹配。
Observe Mode
在 ACT 主模式之外加一个“观察模式”,专门整理工具结果;后来发现它本质上又是一套状态机。 其设想是:ToolCall 之后先不继续行动,而是进入一个只负责整理结果的回合:
raw tool results
│
▼
evidence / known / discard
它的问题也很直接:
- 多了一种模式:ACT 行动,OBSERVE 观察,切换条件要额外维护。
- 触发阈值难调:太早会打断收敛,太晚上下文已经膨胀。
- 保留规则变复杂:什么进 evidence,什么进 known,什么 discard,经常要补规则。
- 行动节奏变慢:原本看完结果就能继续,现在中间多了一轮整理。
后来我把它拆回更简单的机制:
- 普通工具结果:有界输出 +
Recall. - 旧对话历史:上下文压缩.
Read/Edit:进入FILE STATE.
这样就不需要专门保留一个 OBSERVE 状态。
节省 Token
也测试过一些 Bash 输出压缩工具,比如 rtk 和 snip。 它们的方向很有吸引力,但最后我还是放弃了:节省 Token 不能以改变输出语义为代价。
这类工具的目标是减少命令输出进入 LLM 上下文的 Token。 思路上略有不同:rtk 更像是改写命令输出,snip 更像是 filter 输出。
问题在于,它们都很难保证一件事:压缩后不改变输出语义。
我实际测试过一个 pytest 不支持参数的例子,两个工具都改写了输出语义。 这类问题很危险,因为 LLM 看到的是处理后的输出,它很难知道原始命令到底发生了什么。

这里的核心问题是:这些工具往往能做好“预期内”的正向工作,比如压缩列表、摘要日志、减少重复内容。 但很难兜住“预期外”的逆向情况,比如参数错误、异常格式、工具版本差异、插件输出、非标准 stderr。
而 Bash 工具生态太大了。要想长期准确支持各种命令的输出格式、参数解析和异常路径,并持续维护好,并不是一件轻量的事情。
所以这条路我也暂时放弃了。到目前为止,我还没有找到一个足够简单、足够泛化的方案。
结尾 ¶
最后的体验来说,Agent 开发确实是一件非常细腻、有意思的事情。
如果说要总结几句话的话:
- Agent Loop 是一个基于反馈不断调整的可收敛过程。
- 上下文设计是关键。
- 多让 AI 决策,而不是把流程写死。
- 追求泛化。
最后欢迎 star: https://github.com/hit9/nanocode
本文原始链接地址: https://hit9.dev/post/nanocode


