1

随着 AI 编码助手在软件开发中日益普及,我们发现传统的 CLI 工具(主要为人类交互而设计)在与大语言模型 (LLM) 协作时往往显得力不从心。本文记录了我们如何重新设计 Calcit 的命令行界面,使其真正对 LLM 友好,在保持(甚至提升)开发体验的同时,显著降低了 Token 消耗。

背景:Calcit 快照格式

Calcit 是一门类似 Lisp 的函数式编程语言,使用 Cirru 语法(基于缩进的 S-表达式)。与分散在各个目录中的传统源文件不同,Calcit 将整个程序存储在名为 compact.cirru 的单一结构化快照文件中。

快照结构

一个典型的 Calcit 快照包含:

{} (:package |app)
  :configs $ {}
    :init-fn |app.main/main!
    :reload-fn |app.main/reload!
    :version |0.10.4

  :files $ {}
    |app.main $ %{} :FileEntry
      :defs $ {}
        |main! $ %{} :CodeEntry
          :doc |"Main entry point"
          :code $ quote
            defn main! ()
              println |"Hello, Calcit!"
          :examples $ []

        |add $ %{} :CodeEntry
          :doc |"Addition function for two numbers"
          :code $ quote
            defn add (a b)
              &+ a b
          :examples $ []
            quote $ add 1 2
            quote $ add 10 20

      :ns $ %{} :CodeEntry
        :doc |"Main application namespace"
        :code $ quote
          ns app.main $ :require
            app.lib :as lib
        :examples $ []

关键结构元素:

  • :configs - 项目元数据(入口函数、版本)
  • :files - 命名空间 -> 文件条目的映射
  • %{} :FileEntry - 包含 :defs(定义)和 :ns(命名空间声明)
  • %{} :CodeEntry - 每个定义都有 :doc:code:examples
  • :code $ quote - 实际代码以引用数据形式存储(同质性)

对于一个简单的 3 行函数,原始 JSON 表示会消耗约 300 个 Token。当探索拥有数十个函数的代码库时,Token 成本会迅速飙升。

洞察: 除非 LLM 需要通过程序操作代码,否则它们并不需要 JSON。Cirru 语法完全可读,且更加紧凑。

弥补语法鸿沟:cr cirru

我们发现的一个直接障碍是,虽然 Cirru 很紧凑,但 LLM 往往带有“Lisp 包袱”——期望标准的括号,并难以理解 Cirru 特有的缩进和叶节点前缀(如字符串的 |)。

为了解决这个问题,我们提供了 cr cirru,这是一套转换工具,允许智能体在提交修改前验证其对语法的理解。

# 验证 Cirru 字符串如何转换为 JSON
$ cr cirru parse '|hello world'
"hello world"

# 验证表达式如何映射到 AST 结构
$ cr cirru parse 'defn add (a b) (&+ a b)'
[["defn","add",["a","b"],["&+","a","b"]]]

我们还包含了一个 cr cirru show-guide 命令,这是一个 50 行的 Cirru 语法规则简要总结。智能体被指示每会话阅读一次,确保它们理解 $(嵌套)和 ,(注释)等标记,而不需要成千上万个 Token 的训练数据。

绘制蓝图:高层级探索

在深入研究具体的代码节点之前,LLM 智能体需要了解“地势”。在传统项目中,这通常涉及运行 ls -Rgrep。在 Calcit 中,我们提供了一些结构化的切入点,它们直接使用 AST 的语言。

1. 列出命名空间:cr query ns

智能体直接查询快照的模块,而不是遍历文件系统并猜测哪些文件是相关的。

$ cr query ns
Project namespaces: (6 namespaces)
  app.$meta
  app.comp.container
  app.config
  app.main
  app.schema
  app.updater

Tip: Use `--deps` to include dependency and core namespaces.

2. 结构分析:cr analyze call-graph

为了理解这些碎片如何组合在一起,智能体可以从配置中指定的入口点开始分析调用图。

$ cr analyze call-graph
# Call Tree Analysis

**Entry Point:** `app.main/main!`

## Call Tree Structure

└── app.main/main!
    ├── app.main/render-app!
    │   ├── respo.core/render!
    │   ├── app.comp.container/comp-container
    ├── reel.util/listen-devtools!
    └── app.main/persist-storage!

这个简化后的树告诉智能体哪些函数是关键的,以及它们如何相互依赖,在不阅读任何实现逻辑的情况下提供了一张脑图。

3. 定位目标:cr query search

一旦智能体知道要调查哪个命名空间或函数,它需要找到具体的逻辑所在。它不再需要阅读数百行的函数并数括号,而是使用结构化搜索来找到精确的坐标。

$ cr query search "render-app!" -f 'app.main/main!' -l
Results: 2 match(es) found in 1 definition(s):

● app.main/main! (2 matches)
    [5,0] in render-app!
    [6,3,2,0] in render-app!

这返回了准确的 AST 坐标 ([5,0])。智能体不再需要具备完美的缩进空间推理能力;它只需跟随搜索引擎提供的路径进行精确编辑。

4. 生命周期管理:cr edit

当需要构建或重构时,智能体不会“创建文件”或“写入字符串”。它使用带有操作反馈的结构化 edit 命令。

$ cr edit def app.services/new-fn -e 'defn new-fn () (println |hello)'
✓ Created definition 'new-fn' in namespace 'app.services'

Next steps:
  • View definition: cr query def 'app.services/new-fn'
  • Find usages: cr query usages 'app.services/new-fn'
  • Add to imports: cr edit add-import <target-ns> 'app.services' --refer 'new-fn'

通过提供高层级的生命周期命令并建议后续逻辑步骤,我们消除了 LLM 迷失方向或通过直接文本操作破坏快照结构化元数据的风险。

方案一:渐进式展示

对于一个简单的 3 行函数,原始 JSON 表示会消耗约 300 个 Token。当探索拥有数十个函数的代码库时,Token 成本会迅速飙升。

洞察: 除非 LLM 需要通过程序操作代码,否则它们并不需要 JSON。Cirru 语法完全可读,且更加紧凑。

我们实现了一个三层探索模型:

第一层:cr query peek - 快速概览

$ cr query peek app.main/add

Definition: app.main/add
Doc: Addition function for two numbers
Expr: defn add (a b) (&+ a b)
Examples: 2

Tips:
  - cr query def app.main/add
  - cr query examples app.main/add
  - cr query usages app.main/add
  - cr edit doc app.main/add '<doc>'

结果: 一个简明的功能签名和文档摘要。非常适合扫描多个函数。

第二层:cr query def - 完整源码

$ cr query def app.main/add

Definition: app.main/add
Doc: Addition function for two numbers
Examples: 2

Cirru:
defn add (a b)
  &+ a b

Tips: try `cr query search <leaf> -f 'app.main/add' -l` to quick find coordination...
      use `cr tree show app.main/add -p "0"` to explore tree for editing.
      add `-j` flag to also output JSON format.

结果: 以可读的 Cirru 格式显示完整实现。仅在明确需要时通过 -j 标记提供 JSON,从而节省大量 Token。

第三层:cr query def -j - 程序化访问

$ cr query def app.main/add -j

Definition: app.main/add
Doc: Addition function for two numbers
Examples: 2

Cirru:
defn add (a b)
  &+ a b

JSON:
["defn","add",["a","b"],["&+","a","b"]]

Tips: ...

结果: 在需要机器处理时提供完整输出。

细粒度导航:cr tree show

$ cr tree show app.main/add -p "0"

Location: app.main/add  path: [0]
Type: list (4 items)

Cirru preview:
  defn add (a b)
    &+ a b

Children:
  [0] "defn" -> -p "0,0"
  [1] "add" -> -p "0,1"
  [2] (2 items) -> -p "0,2"
  [3] (3 items) -> -p "0,3"

Next steps: To modify this node:
  • Replace: cr tree replace app.main/add -p "0" -j '<json>'
  • Delete:  cr tree delete app.main/add -p "0"

Tips: Use -j '"value"' for precise leaf nodes, -e 'cirru code' for expressions; add -j flag to also output JSON format

结果: 节点级别的探索,仅在明确要求时显示 JSON。

查看示例:cr query examples

当函数有记录的示例时,可以单独查看:

$ cr query examples app.main/add

Examples for: app.main/add
2 example(s)

[0]:
  add 1 2
  JSON: ["add","1","2"]

[1]:
  add 10 20
  JSON: ["add","10","20"]

Tip: Use `cr edit examples app.main/add` to modify examples.

结果: 以 Cirru(用于阅读)和 JSON(用于程序化使用)显示示例,帮助 LLM 在不检查整个代码库的情况下理解使用模式。

上下文提示:引导下一步

其中最具影响力的改进是在每个命令输出中添加了 上下文 提示。我们不再提供通用的帮助文本,而是根据当前上下文提供具体的后续步骤。

示例:渐进式提示

搜索之后:

$ cr query search "render-app!" -f 'app.main/main!' -l
Search: Searching for:
  render-app! (contains)
  Filter: app.main/main!

Results: 2 match(es) found in 1 definition(s):

● app.main/main! (2 matches)
    [5,0] in render-app!
    [6,3,2,0] in render-app!

Next steps:
  • View node: cr tree show '<ns/def>' -p "<path>"
  • Batch replace: See tip below for renaming 2 occurrences

Tip for batch rename:
  Replace from largest index first to avoid path changes:
    cr tree replace 'app.main/main!' -p "6,3,2,0" --leaf -e '<new-value>'
    cr tree replace 'app.main/main!' -p "5,0" --leaf -e '<new-value>'

⚠️  Important: Paths change after each modification!

查看节点之后:

$ cr tree show app.main/main! -p "5"
Location: app.main/main!  path: [5]
Type: list (1 items)

Cirru preview:
  render-app!

Children:
  [0] "render-app!" -> -p "5,0"

Next steps: To modify this node:
  • Replace: cr tree replace app.main/main! -p "5" -j '<json>'
  • Delete:  cr tree delete app.main/main! -p "5"

修改之后:

$ cr tree replace app.main/add -p "2,0" --leaf -e '*'
✓ Applied 'replace' at path [2,0] in 'app.main/add'

From:
"+"

To:
"*"

Next steps:
  • Verify: cr query def 'app.main/add'
  • Find usages: cr query usages 'app.main/add'

智能错误提示

当操作失败时,我们提供可操作的指导:

$ cr tree show app.main/main -p "99,2,1"

Error: Invalid path
Path index 99 out of bounds at depth 0 (list has 10 items)

→ Longest valid path: root
→ Node at that path: defn main () ... (10 items)

Available: This node has 10 children (indices 0-9)
→ View it with: cr tree show app.main/main -p ""

Hint: First few children:
  [0] "defn" -> "0"
  [1] "main" -> "1"
  [2] [] (0 items) -> "2"
  ... and 7 more

影响: LLM 可以自行纠正,而不需要人工干预。

文档集成

我们将 Calcit 的指南直接集成到了 CLI 中:

$ cr docs search "macro"

Found 12 matches in 3 files:

quick-reference.md (quick-reference.md)
------------------------------------------------------------
  54: ; Thread macro
  55: -> data
  56:   filter some-fn
  57:   map transform-fn

features.md (features.md)
------------------------------------------------------------
   9: - **Lisp syntax** - Code as data, powerful macro system
  10: - **Hot code swapping** - Live code updates during development
  ...
  27: - [Macros](features/macros.md) - Code generation and syntax extension

Tip: Use `cr docs read macros.md` to view full content
     Use `cr docs read features/macros.md` for detailed guide

其他文档命令:

$ cr docs list                    # 列出所有可用文档
$ cr docs read macros.md -s 20    # 从第 20 行开始阅读
$ cr docs read intro.md -n 50     # 阅读前 50 行

结果: LLM 可以在不离开编码上下文或调用外部来源 API 的情况下查询文档。

增量开发工作流

当这些工具组合在一起时,真正的力量就显现出来了:

典型的 LLM 辅助开发过程

  1. 探索代码库:

    cr query ns                    # 列出所有命名空间
    cr query defs app.main         # 命名空间中的函数
    cr query peek app.main/add     # 快速确认签名
  2. 理解实现:

    cr query def app.main/add      # 完整代码(仅 Cirru)
    cr query usages app.main/add   # 在哪里被使用了?
  3. 定位修改点:

    cr query search "+" -f app.main/add -l
    # 发现于路径 [2,0]
  4. 查看并修改:

    cr tree show app.main/add -p "2,0"
    cr tree replace app.main/add -p "2,0" --leaf -e '*'
  5. 增量验证:

    cr edit inc --changed "app.main/add"
    # Watcher 自动重新编译
    cr query error  # 检查问题

Token 效率: 与每个命令都输出完整 JSON 和冗长错误消息相比,这套工作流消耗的 Token 显著减少。

习得的设计原则

1. 渐进式展示优于完整性

不要一次性倾倒所有信息。根据可能的后续操作分层提供信息:

  • Peek -> 签名和元数据
  • Read -> 完整实现
  • JSON -> 程序化操作

2. 上下文引导优于通用帮助

每个输出都应该建议最有价值的下一个命令:

  • 搜索后 -> 展示如何查看结果
  • 查看后 -> 展示如何修改
  • 修改后 -> 展示如何验证

3. 人读优先,机器按需

默认使用 LLM 自然阅读的格式(代码语法,而非 JSON)。通过明确的标记(-j, --json)提供结构化格式。

4. 错误消息即导航辅助

失败的操作应该:

  • 解释 什么 地方出错了
  • 展示 最长有效路径
  • 列出 可用选项
  • 建议 纠正性命令

5. 集成参考资料

不要假设能访问外部文档。为语言文档、示例和 API 参考提供 searchread 命令。

对比:Calcit CLI 与传统文件工具

在使用 LLM 辅助开发时,效率瓶颈通常在于智能体如何感知和修改世界。以下是 Calcit CLI 与传统工作流(如 Rust 或 Python 配合 Copilot/Cursor)的对比。

1. 文档访问:窄上下文与宽上下文

传统方式 (Rust/Python):

  • 差距: LLM 通常依赖其训练数据(可能已过时)或外部“网页搜索”工具。
  • 噪声: 阅读文档通常需要抓取整个网页或大型 Markdown 文件,消耗成千上万个探索性 Token。
  • 摩擦: 如果项目使用特定的内部库,开发者必须手动将文档复制粘贴到提示词中。

Calcit CLI:

  • 在上下文发现: 通过 cr docs searchread,LLM 可以精确查询所需的章节(例如“宏如何处理 ~@”)。
  • 集成库: cr libs readme 让智能体无需离开终端即可探索第三方模块文档,确保文档和代码版本始终同步。
  • 效率: 智能体在一个针对性的命令中完成了从“我需要知道 X”到“我有了说明 X 的 20 行内容”的转变。

2. 代码修改:结构化与文本化

传统方式 (基于文本的 Diff):

  • “迷失文件”问题: 修改 500 行的文件时,LLM 经常遗漏章节(// ... 现有代码 ...)或产生行号幻觉,导致文件损坏。
  • 缩进脆弱性: 在缩进敏感语言中,文本搜索替换中一个放错位置的空格就会破坏整个模块。
  • 上下文开销: 为了安全编辑一个函数,智能体通常觉得需要阅读整个文件以确保不破坏周围的范围。

Calcit CLI (基于树的编辑):

  • 外科手术般的精度: 通过使用 cr tree show 找到路径(如 [2,0,1])并使用 cr tree replace 更新它,智能体执行的是结构化修改。由于 CLI 处理了重构过程,因此不可能破坏缩进。
  • 极简上下文: 智能体只需要看到它正在修改的特定 AST 节点。它不需要加载同一个文件中的其他 20 个函数,仅仅是为了避免迷路。
  • 验证循环: CLI 立即返回修改前后的结构,允许 LLM 在不重新阅读整个文件的情况下验证其逻辑。

总结:信噪比 (SNR)

特性传统工作流 (标准文件)Calcit CLI 工作流 (快照 + 树)
探索文档高噪声 (浏览器抓取, 手动粘贴)高信号 (cr docs 针对性读取)
定位代码模糊 (grep/搜索通常缺乏结构)精确 (cr query search 返回 AST 路径)
修改代码风险 (diff, 行号, 缩进)安全 (结构化节点替换)
验证沉重 (完整重解析, 手动检查)轻量 (即时本地对比和 cr query error)

通过将代码和文档视为可查询的数据库,而不是文本文件的集合,我们让 LLM 能将更多时间花在“思考”上,而不是“排版”上。

对开发工作流的影响

虽然很难量化每个项目的确切 Token 节省量,但开发体验的转变是深远的。通过针对 LLM 交互进行优化,我们观察到了几个定性的改进:

  • 减少噪声: 渐进式展示模型确保 LLM 只“看到”相关的代码和元数据,防止模型被冗长的 JSON 结构淹没。
  • 提升自愈能力: 准确的错误消息和上下文提示允许 AI 智能体独立解决失败,大幅减少了在复杂重构期间对人类“手把手教”的需求。
  • 降低认知负担: 即使对于人类开发者,更清晰的 CLI 输出也使得扫描定义和在 AST 中寻找特定节点变得更容易。
  • 更快的迭代: 增量验证和热重载的结合带来了一个紧凑的反馈循环,感觉比传统的构建运行周期更具响应性。

数数难题:通过索引导航 AST

尽管基于树的编辑精度很高,我们还是遇到了一个独特的挑战:LLM 的数数能力出奇地差。

在早期迭代中,我们注意到智能体通常需要 3-5 次尝试才能命中正确的节点。当 LLM 看到一系列表达式时,它经常难以一致地将视觉元素映射到其确切的数值索引。在深层或宽大的 AST 结构中,这表现为一系列特定的失败。

观察到的退化

  • 差一错误 (Off-By-One): 智能体在定位长列表中的兄弟节点时,可能明明想指 index 4 却写了 3。
  • 深度路径幻觉: 在像 [6,3,2,0,1] 这样的复杂嵌套结构中,智能体可能迷失层级并“捏造”出不存在的路径。
  • 索引漂移陷阱: 在执行多次编辑时,智能体经常忘记删除或插入节点会改变后续所有兄弟节点的索引。

补救措施

为了减轻这些“数数幻觉”,我们演进了 CLI,由其代表智能体执行坐标计算:

  1. 搜索优于计算: 而不是要求智能体“找到第 5 个参数”,我们提供了 cr query search,它根据内容识别出确切路径(如 [5,0])。智能体从 计算 坐标转变为 复制 坐标。
  2. 显式子节点路径:cr tree show 中,我们用即插即用的 CLI 参数替换了内部表示显示:[0] "render-app!" -> -p "5,0"。这鼓励智能体将路径视为一个字面量字符串,直接用于下一个命令。
  3. 错误中的路径引导: 当智能体提供无效路径时,CLI 不仅仅是报错。它会列出有效的兄弟节点及其索引(例如 Available: indices 0-9),允许智能体通过“观察”正确选项来纠正自己。
  4. 批量修改逻辑: 当发现多处匹配时,CLI 明确提供“逆序”操作命令(从最大索引开始)。这确保了尽管发生了之前的编辑,每个后续路径依然有效,如果提供了这样的序列,LLM 能够很好地遵循这一概念。

通过承认 LLM 将代码感知为 Token 序列而非结构化对象,我们将 AST 导航的重担从 AI 的推理引擎转移到了 CLI 的输出中。

结论

我们这段旅程的关键洞察是:对 LLM 友好的工具同样造福人类。

通过专注于:

  • 渐进式展示
  • 上下文引导
  • 选择性详尽
  • 集成文档

我们创建了一个对 AI 助手高效且对人类开发者直观的 CLI。显著的 Token 减少直接转化为成本节省,但更重要的是,它降低了 LLM 及其人类协作者的认知开销。

随着 AI 编码助手变得无处不在,工具设计者应该问:“LLM 能高效使用它吗?”答案往往会导向对每个人都更好的工具。


尝试 Calcit: https://github.com/calcit-lang/calcit

CLI 文档: 参见仓库中的 docs/Agents.md,获取 Calcit 与 LLM 辅助开发的完整指南。


题叶
17.3k 声望2.7k 粉丝

Calcit 脚本语言作者. 图形学爱好者.