Nanocode 的设计思路和开发笔记

本文记录我开发的一个轻量 AI Coding Agent - Nanocode 的开发笔记.

项目地址: https://github.com/hit9/nanocode, 欢迎 star!

模型切换

nanocode 理论上可以支持所有 OpenAI Chat-Completion 风格的 API Providers. 我测试过的有 deepseek, aliyun, opencode-go, zenmux llama.cpp, 测试下来的体验:

  1. GPT-5.5 可以说一骑绝尘, “操刀精准”.
  2. Deepseek-v4-flash 性价比很高.
  3. 本地量化模型中 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 结束的条件是:

  1. LLM 不再调用工具.
  2. 或: 超过了设定的 Max-Steps 阈值:

    这是必要的设计, 因为我们无法严格保证 LOOP 会最终结束. 给 LLM 加上一些 “记忆” 支持, 它可以把试过的 “死胡同” 记下来则可以有效避免死循环, 但无法 100% 保证.

这种循环模式中, 关键点就是两个: “反馈” + “调整”:

  • LLM 要有机会可以知道环境现状, 出过错.
  • LLM 要有机会可以对历史 Work 进行进一步调整修正, 最终收敛到目标.

可收敛

曾观察过一个只有 GPT5.5 可以拿下的 BUG 的调查过程, 其收敛过程类似于 图上深度优先遍历. 它会沿着当前假设批量的收集证据,拿到充分的上下文后,才下手改动.

首先我们在 “黑板” 上写一个问题 (目标), LLM 提出 N 种假设, 接下来深度优先地测试各个假设:

  1. 如果验证, 则达成目标
  2. 如果证伪, 则黑板上划掉这个假设, 继续下一个.
  3. 在此期间, LLM 不断调查环境收集证据, 也可以调整假设列表.

从控制论的角度看, 可能性空间必须不断收缩, 才是有效的控制过程. 也就是说, Agent LOOP 过程必须是 “可收敛的” , 因此一定要有一个可以记录和追踪的地方, 这个就是 上下文 (Context).

工具设计

工具列表

目前支持的完整工具列表:

我认为, 设计好一个 Tool 的要义是: 要从 LLM 的视角来看, 它是否足够清晰明确:

  1. 保持简单: 复杂的工具设计更容易造成调用失败和理解偏差.
  2. 无歧义: 清晰、简洁、一致的调用姿势.
  3. 单一职责: 最好一个工具干一个事,工具之间能力无交叉.
  4. Examples: 典型的使用场景, 给出调用示例.
  5. 明确 Return: 一定要告诉 LLM 它使用了这个 Tool 后将会得到什么.
  6. 区分 Prompt 和 Tool Description: 核心是单一职责:
    • 描述 Tool 自身的, 放在 Tool Description 里.
    • 不同工具的选用偏好则放在 Prompt 中.

批量调用

我倾向于赋予工具批量调用的能力,尤其是探索阶段,有利于加速收敛.

比如说:

  • Search 可以一次性搜索多个 pattern: Search A|B|C
  • Read 可以一次性读取多个 range: Read code1.py 1:100 code2.py 200:300
  • Edit 也可以一次性操作多个点位.
  • 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 “犯过的错误不要再犯”.

每条错误消息的设计上,尽量做到两点:

  1. 哪里错了: 一般就是错误消息,比如说 “Bash requires exactly one argument”
  2. 怎么做才是正确的: 明确告诉正确的做法是什么, 比如说 “try Bash(command)”

有界输出和召回

工具结果可能会很长, 必须要做 “Bounded Output” 设计.

不可依赖 LLM 来帮助裁剪工具输出

某些情况下 LLM 会有意识地避免过大输出, 比如说:

pytest -v 2>&1 | tail -n 10

但是,程序不可依赖这一行为,这种 approach 有 3 个问题:

  1. 依赖模型智力 和 稳定性.
  2. 分散模型注意力.
  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>

可以看到:

  1. 采用了 XML-like 的形式组装,因为要考虑到代码缩进、换行,这比 JSON 更适合.
  2. 左边是 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 和 行号 就会失效,这之间存在前后依赖关系.

解决思路是:先在内存里把所有编辑预演一遍,确认没问题后再真正写文件

  1. 在内存里维护一份临时文件状态
  2. 临时文件里的每一行除了有内容,还会记录一个 origin,表示它来自原始文件的第几行。
  3. 这样即使前面的 Edit 导致行号漂移,后面的 Edit 仍然可以通过 origin 找回真正要修改的那一行。
举个批量编辑的例子 (推演过程)

原始文件是:

0: a
1: b
2: c

现在同一个 batch 里有两个 Edit:

  1. Edit 1: 在 1:hash(b) 前插入 x
  2. 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 负责在行号漂移后把这行找回来。

符号索引

最开始用过 codegraphcymbal, 类似的工具还有一些,都是基于 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:2785
    
  • inspect: 看一个符号, 返回带编辑锚点的源码,成员,引用,实现方.

    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:217
    
  • outline: 看一个文件或一个符号内的结构树

    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 会自动维护索引更新

  1. 启动后自动异步后台更新 (status bar 可见).
  2. 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 个部分:

  1. SYSTEM_PROMPT: 系统提示词,无变量、最稳定.
  2. Environment:不是真实的聊天历史,当前环境信息,很稳定.

    Environment 消息内容示例
     --- Environment ---
     - cwd: /Users/hit9/dev/github/nanocode
     - os: Darwin
     - arch: arm64
     - shell_timeout: 30s
     - detected_commands: bash, git, rg, python3, pytest
    
  3. chat 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>
    
  4. Memory:非真实聊天历史. 这里主要包括 High-Level 的 “记忆”, 也就是 LLM 的记事本.

  5. 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。 这也是足够泛化的做法。

文件视图

除了 ReadEdit 这两个工具,其他工具的结果分两层记录:

  • 作为历史聊天消息放在上下文中 (超界时摘录).
  • 存在内存中,可召回. (直到无消息再引用它时永久消失).

那么,对于文件读写,LLM 更合适的阅读视角是什么?我的思路是: 不要让模型从历史 tool output 里推断文件状态,而是直接给它一个“当前文件视图”

ReadEdit 的结果会被抽取出来,汇总到尾部的 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 的区别

前者是最近的 ReadEdit 操作流水,后者只筛出最近的 代码 文件修改,用来提醒模型需要做检查。

而历史消息中, ReadEdit 的消息会折叠成很简单的一行到文件视图的 “指针” 记录:

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 之前可压缩的旧聊天历史

当前这一轮正在发生的消息不会被改写;MemoryFILE 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

exploreverify 交给小模型,理论上可以降低主模型噪音和成本;实际同步成本太高

放弃原因主要有几个:

  1. 上下文变成两层:主 Agent 看全局,子 Agent 看局部,设计复杂度明显上升
  2. 反馈环变长:委派、汇报、再理解、再决策,中间会丢细节,主 Agent 经常还要重新确认。
  3. 委派颗粒度不好控制:主 Agent 容易把一个过大的任务直接丢给子 Agent。
  4. 汇报粒度难校准:太长会把噪音带回来,太短又不足以支撑判断。

SubAgent 方向本身不一定错,但它需要更细腻的上下文分层和任务拆解能力,和 nanocode 的轻量定位不匹配

Observe Mode

在 ACT 主模式之外加一个“观察模式”,专门整理工具结果;后来发现它本质上又是一套状态机。 其设想是:ToolCall 之后先不继续行动,而是进入一个只负责整理结果的回合:

raw tool results
    │
    ▼
evidence / known / discard

它的问题也很直接:

  1. 多了一种模式:ACT 行动,OBSERVE 观察,切换条件要额外维护
  2. 触发阈值难调:太早会打断收敛,太晚上下文已经膨胀。
  3. 保留规则变复杂:什么进 evidence,什么进 known,什么 discard,经常要补规则。
  4. 行动节奏变慢:原本看完结果就能继续,现在中间多了一轮整理。

后来我把它拆回更简单的机制:

  • 普通工具结果:有界输出 + Recall.
  • 旧对话历史:上下文压缩.
  • Read/Edit:进入 FILE STATE.

这样就不需要专门保留一个 OBSERVE 状态

节省 Token

也测试过一些 Bash 输出压缩工具,比如 rtksnip。 它们的方向很有吸引力,但最后我还是放弃了:节省 Token 不能以改变输出语义为代价

这类工具的目标是减少命令输出进入 LLM 上下文的 Token。 思路上略有不同:rtk 更像是改写命令输出,snip 更像是 filter 输出。

问题在于,它们都很难保证一件事:压缩后不改变输出语义

我实际测试过一个 pytest 不支持参数的例子,两个工具都改写了输出语义。 这类问题很危险,因为 LLM 看到的是处理后的输出,它很难知道原始命令到底发生了什么。

这里的核心问题是:这些工具往往能做好“预期内”的正向工作,比如压缩列表、摘要日志、减少重复内容。 但很难兜住“预期外”的逆向情况,比如参数错误、异常格式、工具版本差异、插件输出、非标准 stderr。

而 Bash 工具生态太大了。要想长期准确支持各种命令的输出格式、参数解析和异常路径,并持续维护好,并不是一件轻量的事情。

所以这条路我也暂时放弃了。到目前为止,我还没有找到一个足够简单、足够泛化的方案。

结尾

最后的体验来说,Agent 开发确实是一件非常细腻、有意思的事情。

如果说要总结几句话的话:

  1. Agent Loop 是一个基于反馈不断调整的可收敛过程。
  2. 上下文设计是关键。
  3. 多让 AI 决策,而不是把流程写死。
  4. 追求泛化。

最后欢迎 star: https://github.com/hit9/nanocode

本文原始链接地址: https://hit9.dev/post/nanocode

王超 ·
首页 | 归档 | 算法 | 订阅