博客 / 詳情

返回

設計對 LLM 友好的 CLI 工具:Calcit 演進中的經驗教訓

隨着 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 輔助開發的完整指南。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.