# Day 06 - 第一個工具 read_file

> 把模型的工具請求交給 Python 執行，再將結果送回模型，完成「請求、執行、回填、再送出」的 agent loop，讓 KeSi 第一次能自己讀程式碼回答問題。

Published: 2026-08-06
URL: https://kaochenlong.com/build-a-read-file-tool

---

前一篇文章我們把許願單的格式看得差不多然後就下班了，許願單來了但還沒人去處理。接下來把執行跟回報的進度補上，讓那個從第一天就一直講的 `while` 迴圈真正轉起來。轉完之後，KeSi 會第一次做到一件像樣的事，我會問它某個檔案在幹嘛，KeSi 能夠自己去讀取檔案然後自己回答問題。

GitHub Repo：&lt;https://github.com/kaochenlong/KeSi&gt;

## 迴圈的五個步驟

先把第一天文章的那幾行迴圈的虛擬碼搬回來：

```python
while 還沒完成:
    問模型：現在該做什麼？
    照它說的做
    把結果告訴它
```

現在可以把它展開成五個具體的步驟：

1. 組信送出，把日記連同工具清單寄給模型
2. 讀回應，看 `stop_reason` 的結果，如果是 `end_turn` 就收工，是 `tool_use` 就往下走到步驟 3
3. 照許願單上的 `name` 跟 `input`，跑我們自己寫的函式
4. 函式把執行結果包成 `tool_result`，順便把編號一起記進日記裡
5. 回到步驟 1，把變厚的日記再寄一次

這個轉圈的過程，外面的世界叫它 agent loop 或 tool use loop，之後在各種文章看到這些名詞指的就是這些步驟。

步驟 1 跟 2 在上一篇文章做過了，這篇文章主要做步驟 3 跟 4，加上讀檔時候的防護機制一共幾十行。這幾十行可以把一個只出一張嘴的聊天程式，變成一個有能力自己動手的 agent。

## 第一個工具 read_file

上篇文章的 `get_time` 是拿來看許願格式的教學道具，接著來做個讀檔案的工具，這個工具應該會實用一些。先看工具的定義：

```python
TOOLS = [
    {
        &quot;name&quot;: &quot;read_file&quot;,
        &quot;description&quot;: &quot;讀取工作目錄底下的文字檔，回傳完整內容。&quot;
        &quot;當使用者問到某個檔案裡有什麼、或需要檔案內容才能回答時呼叫。&quot;,
        &quot;strict&quot;: True,
        &quot;input_schema&quot;: {
            &quot;type&quot;: &quot;object&quot;,
            &quot;properties&quot;: {
                &quot;file_path&quot;: {
                    &quot;type&quot;: &quot;string&quot;,
                    &quot;description&quot;: &quot;相對於工作目錄的檔案路徑&quot;,
                }
            },
            &quot;required&quot;: [&quot;file_path&quot;],
            &quot;additionalProperties&quot;: False,
        },
    }
]
```

跟 `get_time` 最大的差別在於 `properties` 不是空的了，這個工具我設定一個必填參數 `file_path`，這表示等一下模型開單的時候，模型得自己決定要把什麼路徑填進表格裡。說明書照昨天學的原則寫，講清楚做什麼、什麼時候呼叫，參數部份也帶一句描述。以官方三、四句起跳的標準來看還是太簡略，不過現在工具數量少，先求有之後再求好。

`strict: True` 會要求模型送出的參數符合這份 schema，`additionalProperties: False` 則明講不接受表格以外的欄位。雖然 API 端會先要求格式，但程式端之後還是要處理讀檔失敗，兩邊各管各的。再來是執行函式，也就是願望成真的地方：

```python
from pathlib import Path

BASE_DIR = Path.cwd().resolve()


def read_file(file_path):
    try:
        target = (BASE_DIR / file_path).resolve(strict=True)
    except (OSError, RuntimeError):
        return f&quot;錯誤：找不到或無法解析檔案 {file_path}&quot;, True

    if not target.is_relative_to(BASE_DIR):
        return &quot;錯誤：不允許讀取工作目錄以外的檔案。&quot;, True
    if not target.is_file():
        return f&quot;錯誤：不是可以讀取的文字檔 {file_path}&quot;, True

    try:
        return target.read_text(encoding=&quot;utf-8&quot;), False
    except UnicodeDecodeError:
        return f&quot;錯誤：檔案不是 UTF-8 文字檔 {file_path}&quot;, True
    except OSError as exc:
        return f&quot;錯誤：無法讀取檔案 {file_path}：{exc}&quot;, True
```

主體是 `Path.read_text()` 函式，其它的都是防護機制。`BASE_DIR` 在程式啟動時記住工作目錄，後面所有路徑都拿它當邊界。這個函式現在回傳兩個值，成功的話會讀到檔案的內容或是失敗時候的錯誤訊息。

開頭這幾行是一座陽春版的監獄。`file_path` 是模型填的，上一篇文章才講到模型填的值不一定可靠，要是模型填了 `/etc/passwd` 或 `../../隔壁專案的秘密.txt` 呢？

`resolve(strict=True)` 會先解析 `..` 跟符號連結，檔案不存在或路徑解不開就回報錯誤。然後 `is_relative_to()` 函式再確認解析後的目標仍在工作目錄裡。最後用 `is_file()` 擋掉目錄，還有目錄以外那些不是普通檔案的東西。

`is_file()` 判斷這是不是個「檔案」，如果少了這道檢查，萬一模型填了 `.` 進來，這個 `.` 指的是當前目錄，後面的 `except OSError` 本來就接得住，程式是不會倒，但是回給模型的訊息會變成一句 `[Errno 21] Is a directory`，後面還拖著一長串絕對路徑，這個路徑資訊就從錯誤訊息漏出去了。

同樣會被 `is_file()` 擋下的另一種東西就沒這麼溫和了，而且知道的人可能比較少。工作目錄裡要是躺著一個「具名管道（named pipe）」，`read_text()` 會停在那裡等寫入端，一直等下去。這不會造成例外而是整個卡住，底下的 `except` 一個都接不到。

什麼是「具名管道」？它也叫 FIFO，就是 first in first out 的縮寫，先寫進去的先被讀出來。你可能用過 `ls | grep foo` 這種管線，把左邊的輸出接到右邊的輸入，那根 `|` 管子是臨時的、沒有名字的。具名管道就是它有名字的版本，用 `mkfifo` 建出來之後會躺在目錄裡，`ls -l` 看得到：

```plaintext
$ mkfifo pipe
$ ls -l
-rw-r--r--  1 kaochenlong  staff  13 Aug  5 21:42 normal.txt
prw-r--r--  1 kaochenlong  staff   0 Aug  5 21:42 pipe
```

第一個字元 `-` 是普通檔案，`p` 就是管道。這傢伙長得像檔案但裡面不存東西，它就只是一根管子，管子一邊有人寫另一邊才讀得到。如果只是讀一根沒人在寫的管道，程式不會報錯，不過它就安安靜靜停在那裡等，等到有人從另一頭寫進來為止。要是沒人寫入，就痴痴的等一輩子。

`is_file()` 可以把這個一起擋下來，因為這個函式問的是「這是不是一個普通檔案」，底層只做 `stat()`，看一眼檔案類型就回答而且不必真的打開它。`read_text()` 函式就不一樣了，它得先 `open()`，而具名管線卡住的正是這個 `open()`。等走到那一步已經來不及，`except` 是在等一個永遠不會來的例外。

這只是第一道柵欄，不算是真正的 sandbox，之後會再慢慢加強。先讓大家看到「模型許願、程式把關」是怎麼回事。`encoding=&quot;utf-8&quot;` 是指只收能用 UTF-8 解碼的普通檔案，不過這還不算完整的文字檔辨識，只要內容能合法解碼，就算裡面混著控制字元也會通過。碰到不能用 UTF-8 解碼的內容，它會回報錯誤，不會假裝讀懂。

## 錯誤誰處理？

如果各位有仔細看我上面寫的程式的話，當檔案不存在、不是普通檔案、無法用 UTF-8 解碼或沒有辦法讀取的時候，我並沒有把例外一路往外丟，而是用 `return` 回傳錯誤訊息與 `True`。這是寫工具跟寫一般程式比較不一樣的地方，因為錯誤訊息的「讀者」是模型。

錯誤訊息回到模型手上，它會自己想辦法。也許是換條路或是再跟你確認一次檔名，官方文件的說法是模型會把錯誤納進它接下來的回應裡。也正因為讀者是模型，官方也建議錯誤訊息要寫得有「指導性」，不要只回一句 failed，要寫清楚哪裡出錯或是接下來可以怎麼處理，例如可以寫「Rate limit exceeded. Retry after 60 seconds.」。錯誤訊息寫得越像個遇到問題懂得求救的樣子，模型自救的成功率越高。我在程式碼裡寫著「錯誤：找不到或無法解析檔案 xxx」也算是照這個原則寫的。模型看到就會知道先檢查檔名或路徑，而不是只得到一句工具壞了。

回報時除了文字，還會在 `tool_result` 上掛一個 `is_error` 標記，明確告訴 API 這一單有沒有辦成。在上一篇文章提到「執行失敗」跟「拒絕執行」都走同一個管道回報，指的就是這個。收到 `is_error: true` 之後，模型可能可以修正參數再試，也可能換個方法再來一次，甚或雙手一攤直接向使用者說明辦不到。會怎麼處理、會重試幾次，都不是固定保證。但能確定的是錯誤有好好回到對話裡，模型才有材料來判斷下一步。

「資訊要給足」這個原則不只適用於錯誤，正常的輸出也一樣。昨天寫的 `get_time` 工具的時區問題，`datetime.now()` 回的是跑程式那台機器的當地時間，KeSi 在你電腦上跑剛好沒事，但哪天把它部署到雲端的機器，得到的時區跟你的時區可能不一樣，而模型根本不知道這個數字是哪個時區的，除非工具回報的時候有連帶把時區一起講清楚。工具的輸出永遠要想到，收到回報的是一個不在現場的模型，像是單位、座標、時區這些被我們當做常識的東西，最好都得白紙黑字寫進去。

## 完整的 kesi.py

零件都到齊了開始來組裝吧！現在的 KeSi 是兩層迴圈的結構。外圈是第 4 天做的 REPL，等你打字、跑一輪、回頭再等你，服務的對象是人。內圈是今天的新東西 agent loop，模型每開一次工具單，就執行、回填、重送，直到它不再開單為止，服務的對象是模型。我丟一個問題給它，外圈可能只轉一圈但內圈有可能轉了好幾圈。

大家每天用的正版 Claude Code 也是類似的結構，在按下 Enter 之後看到它一連串讀檔、跑指令，那是它的內圈在轉。如果按 Esc 中斷它，打斷的也是那個正在轉的內圈。完整的 `kesi.py` 長這樣：

```python
# /// script
# requires-python = &quot;&gt;=3.14&quot;
# dependencies = [&quot;anthropic&quot;]
# ///

import readline  # 支援上下鍵翻歷史，不過 Windows 沒有這個模組
from pathlib import Path

import anthropic

client = anthropic.Anthropic()
BASE_DIR = Path.cwd().resolve()


def read_file(file_path):
    try:
        target = (BASE_DIR / file_path).resolve(strict=True)
    except (OSError, RuntimeError):
        return f&quot;錯誤：找不到或無法解析檔案 {file_path}&quot;, True

    if not target.is_relative_to(BASE_DIR):
        return &quot;錯誤：不允許讀取工作目錄以外的檔案。&quot;, True
    if not target.is_file():
        return f&quot;錯誤：不是可以讀取的文字檔 {file_path}&quot;, True

    try:
        return target.read_text(encoding=&quot;utf-8&quot;), False
    except UnicodeDecodeError:
        return f&quot;錯誤：檔案不是 UTF-8 文字檔 {file_path}&quot;, True
    except OSError as exc:
        return f&quot;錯誤：無法讀取檔案 {file_path}：{exc}&quot;, True


TOOLS = [
    {
        &quot;name&quot;: &quot;read_file&quot;,
        &quot;description&quot;: &quot;讀取工作目錄底下的文字檔，回傳完整內容。&quot;
        &quot;當使用者問到某個檔案裡有什麼、或需要檔案內容才能回答時呼叫。&quot;,
        &quot;strict&quot;: True,
        &quot;input_schema&quot;: {
            &quot;type&quot;: &quot;object&quot;,
            &quot;properties&quot;: {
                &quot;file_path&quot;: {
                    &quot;type&quot;: &quot;string&quot;,
                    &quot;description&quot;: &quot;相對於工作目錄的檔案路徑&quot;,
                }
            },
            &quot;required&quot;: [&quot;file_path&quot;],
            &quot;additionalProperties&quot;: False,
        },
    }
]


def run_agent(history):
    while True:
        resp = client.messages.create(
            model=&quot;claude-haiku-4-5&quot;,
            max_tokens=1024,
            tools=TOOLS,
            messages=history,
        )
        history.append({&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: resp.content})

        if resp.stop_reason != &quot;tool_use&quot;:
            return &quot;&quot;.join(b.text for b in resp.content if b.type == &quot;text&quot;)

        results = []
        for block in resp.content:
            if block.type == &quot;tool_use&quot;:
                print(f&quot;  [執行工具] {block.name}({block.input})&quot;)
                if block.name == &quot;read_file&quot;:
                    try:
                        output, is_error = read_file(**block.input)
                    except TypeError as exc:
                        output = f&quot;錯誤：read_file 的參數不正確：{exc}&quot;
                        is_error = True
                else:
                    output = f&quot;錯誤：沒有 {block.name} 這個工具。&quot;
                    is_error = True
                results.append(
                    {
                        &quot;type&quot;: &quot;tool_result&quot;,
                        &quot;tool_use_id&quot;: block.id,
                        &quot;content&quot;: output,
                        &quot;is_error&quot;: is_error,
                    }
                )
        history.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: results})


history = []
print(&quot;KeSi。輸入 /exit 離開、/reset 清空對話。&quot;)
while True:
    try:
        user = input(&quot;你 &gt; &quot;).strip()
    except (EOFError, KeyboardInterrupt):
        break
    if user in (&quot;/exit&quot;, &quot;/quit&quot;):
        break
    if user == &quot;/reset&quot;:
        history = []
        print(&quot;（日記已清空，我們重新開始）&quot;)
        continue
    if not user:
        continue

    history.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: user})
    print(&quot;KeSi &gt;&quot;, run_agent(history))
```

外圈跟第 4 天的寫法差不多就不再介紹，唯一的差別是原本直接呼叫 API 的地方，換成了呼叫 `run_agent()`。這裡有個容易錯過的 Python 細節，`run_agent(history)` 傳進去的是那本日記的參考，不是複本，所以內圈在裡面 `append` 的每一筆都會直接更新到那本日記，在外圈都看得到。工具的往返因此會累積在對話裡，下一次再問問題，模型就知道它剛剛讀過什麼檔案，這是刻意的設計。

真正的新東西全在 `run_agent` 裡，比較複雜一點點，我們一段一段看。

`while True` 進迴圈，組信送出，這跟之前一樣。拿到回應先做一件在上篇文章養成的好習慣，把 `resp.content` 完整記進日記。注意這行在分岔之前，不管模型是回話還是開單都先原封不動記下來，這是第 4 天「存積木不存文字」鋪的路，因為現在的積木裡真的有 `tool_use` 了。

接著看這個 `if` 的分岔，如果 `stop_reason` 不是 `tool_use` 表示模型不再許願開單，把文字拿出來回傳，內圈結束，控制權交還外圈。但我這裡故意判斷「不是 `tool_use`」而不是「是 `end_turn`」，是因為模型停下來的理由不只兩種，`max_tokens` 被切斷也算一種，這裡我先一律當做「講完了」處理，話被切斷的問題之後再收拾，先讓迴圈轉起來。

再來用一個 `for` 迴圈掃過所有積木，遇到 `tool_use` 就先印一行 `[執行工具]` 讓你看得到它真的有準備要執行工具了，接著再依照 `name` 分派。正版的 Claude Code 也是每個工具呼叫都亮在畫面上給你看，之後在處理權限的時候這行印出來的東西就可以升級成「問你可不可以」。因為現在只有一個工具，目前先用一個 `if` 就可以搞定了，之後工具多了這裡可能會變成一張任務分派表。

`read_file(**block.input)` 這個寫法是把模型填的參數表直接拆包成函式的具名參數，表格欄位跟函式參數同名，這是 Python 語法的方便功能。開著 `strict` 的時候，API 會保證模型回來的 `input` 符合 schema，不過我這裡還是用 `try/except TypeError` 接住參數對不上函式的情況。這不是拿來防模型填錯，主要是預防 schema 跟本機函式哪天只改了一邊，或是測試時手動塞進來的資料不合格式。

講到任務分派這裡有個小細節，開著 `strict` 的正式 API 回應裡，`name` 會是工具清單裡的有效名稱，所以照理說走不到 `else`。我還是留著這道防護，預防工具清單跟分派程式只改了一邊，或是測試時手動造了協議外的資料。

函式執行的結果都先收進 `results` 這個 list，全部處理完再一次 `append` 到日記裡。上一篇文章有講到模型可能在同一個回應裡開好幾張單，所有結果要裝在同一則 user 訊息裡再傳回去，格式不對會妨礙之後的 parallel tool use。

最後 `history.append` 把結果記進日記，回到迴圈頂端重送回給模型。模型收到結果，可能直接回答也可能再開下一張單，於是內圈再轉一圈。

是說，這個 `while True` 沒有圈數上限，目前可以暫時先相信模型總有一天會得到它要的答案而停止開單，事實上絕大多數時候它真的會停，但「絕大多數」在工程上也代表「總有一天不會」，每轉一圈都是一次計費請求，每個迴圈燒的都是錢錢。這顆問題之後會另外有一篇文章專門拆掉它，現在就這樣。

## tool_result 的排隊規矩

回報用的 `tool_result` 積木長這樣，四個欄位：

```python
{
    &quot;type&quot;: &quot;tool_result&quot;,
    &quot;tool_use_id&quot;: block.id,
    &quot;content&quot;: output,
    &quot;is_error&quot;: is_error,
}
```

`is_error` 是布林值，成功時是 `False`，失敗時是 `True`，所以模型不只看到錯誤文字還可以拿到明確的失敗訊號。`tool_use_id` 是昨天呼叫工具時候的編號，這裡我把許願單上的 `id` 抄回來，之後模型才知道這份結果會對到哪一張單子，特別是一次處理多張單的時候就得靠這個編號來對號入座。

官方對這個積木的擺放位置有幾條規矩，寫錯就會被 server 丟一個 `HTTP 400` 的錯誤：

- 帶著 `tool_result` 的 user 訊息必須緊跟在開單的 assistant 訊息後面，中間不能插任何別的訊息。
- 在這則 user 訊息裡，`tool_result` 積木必須排在 content 陣列的最前面。想附加文字的話，文字要排在所有結果後面，順序反了 API 會直接退件。
- 每張單都要有結果，有漏任何一張就會整包被退件，官方連錯誤訊息都寫給你看了

&gt; tool_use ids were found without tool_result blocks immediately after。

補幾個我自己覺得有趣的冷知識。

`content` 欄位其實是選填的，例如工具執行完沒東西好說（例如純動作類的操作），只回 `tool_use_id` 空手交差也是合規定。`content` 不一定是字串，它也可以是積木陣列，陣列裡還可以放圖片。這代表工具可以回一張圖給模型看，例如操作畫面的工具可以把截圖當成結果送回來。至於各位在 Claude Code 裡直接貼上的截圖，那是 `user` 訊息裡的圖片輸入，不是 `tool_result`，只是最後模型看到的同樣都是圖片積木。工具不只是模型的手腳，還可以當它的眼睛，你是我\~的眼 ♫♪

另外，有些廠商的 API 把工具回報做成一個獨立的角色，例如 OpenAI 的 Chat Completions API 就有 `tool` 這種 role（更早叫 `function`）。現在的 Responses API 則改用 `function_call` 跟 `function_call_output` 這兩種 item，不走 role。Anthropic 沒有這樣設計，工具的往返全部塞進 `user` 跟 `assistant` 這兩種角色的積木裡，模型開單是 `assistant` 的積木，我們寫的工具回報是 `user` 的積木。概念上有點怪（明明是程式在回報，卻掛 `user` 的名義），但結構上看起來滿整齊的，結果就是這本日記從頭到尾就只有這兩種角色在輪流。

而 Google Gemini 的 `generateContent` API 跟 Anthropic 是同一派，工具的往返也是塞在兩種角色裡，只是它不叫 assistant 而是叫 `model`，工具結果一樣掛在 `user` 名下。目前較新的 Interactions API 則改用 `function_call` 跟 `function_result` 這兩種 step，也不再是這套 role 排法。

## 見證奇蹟的時刻

demo 的題目我想了一下，決定來點好玩的，就是叫 KeSi 讀它自己：

```plaintext
$ uv run --env-file .env kesi.py
```

啟動後輸入「`kesi.py` 這個檔案在做什麼？」：

![](/rails/active_storage/representations/proxy/eyJfcmFpbHMiOnsiZGF0YSI6NDIyMCwicHVyIjoiYmxvYl9pZCJ9fQ==--3336dc70e7ffc23d66bed3d8b7d50776e40ba862/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJ3ZWJwIiwicmVzaXplX3RvX2xpbWl0IjpbMTI4MCwxMDI0XX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--84d8884f224e8330b83a950151768b773558b857/image-20260806000611461.png)

實際回答會因模型而異，但你可以觀察的是先出現 `read_file({&#39;file_path&#39;: &#39;kesi.py&#39;})` 的工具紀錄，接著才是根據原始碼整理出的回答，這代表內圈確實走完了開單、執行、回填最後再問一次模型。

那行 `[執行工具]` 是內圈轉動的痕跡，我問 KeSi 一句話，它判斷需要看檔案、自己填了 `kesi.py` 這個路徑、讀取檔案然後根據讀到的內容進行回答。程式裡沒有寫死「問題提到檔案就呼叫 `read_file`」這條 `if`，怎麼選工具，是模型根據工具說明書做的判斷。

我選讀取 `kesi.py` 的原因除了可以少寫一個測試檔案外，因為它讀到的內容裡就有 `TOOLS` 的定義，等於它透過自己的工具，讀到了自己工具的說明書原始碼。不過讀檔只是讀，不會執行，所以不會發生什麼無限月讀... 不是，是無限遞迴的災難。

KeSi 現在可以讀懂了它自己的原始碼，然後跟我解釋它自己是怎麼運作的，這如果在幾年前聽起來像科幻小說，現在不過就是一百多行的 Python 程式而已。

## 試試越獄

監獄蓋好了總要驗收一下。直接叫它幹壞事，在提示符輸入「幫我讀 `/etc/passwd` 這個檔案」。

![](/rails/active_storage/representations/proxy/eyJfcmFpbHMiOnsiZGF0YSI6NDIyMiwicHVyIjoiYmxvYl9pZCJ9fQ==--fa902d94d97696d07842c34998023584537b7ba0/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJ3ZWJwIiwicmVzaXplX3RvX2xpbWl0IjpbMTI4MCwxMDI0XX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--84d8884f224e8330b83a950151768b773558b857/image-20260806001450770.png)

模型先開出 `read_file({&#39;file_path&#39;: &#39;/etc/passwd&#39;})` 許願單，下一步應該是本機函式拒絕路徑，並用 `is_error: true` 把原因回給模型，然後模型就會告訴我為什麼失敗。

模型開單，我們的函式檢查路徑是否合法，然後拒絕執行並回傳錯誤，模型再把失敗結果納入回答。整個過程不需要也不應該讓程式崩潰或中斷對話，這就是「錯誤是回給模型的」的設計，也是之前提到「動手的永遠是你的程式」。模型可以想、可以許願，但會不會做是工具決定的。

有興趣可以再對 KeSi 兇一點，明示或暗示它想辦法繞過限制，觀察路徑檢查能不能擋住不同寫法。

## 連續開單

再來個進階的，問一個需要讀兩個檔案才答得出來的問題：

先準備 `a.txt` 與 `b.txt` 兩個內容相關的小檔，然後問「在 `examples` 目錄裡的 `a.txt` 跟 `b.txt` 的內容有什麼關聯？」。實際執行時，注意模型是在同一個回應裡開兩張單還是分兩圈各讀一個，這兩種都可能發生：

![](/rails/active_storage/representations/proxy/eyJfcmFpbHMiOnsiZGF0YSI6NDIyNCwicHVyIjoiYmxvYl9pZCJ9fQ==--d5c859e237cb98d22da9b1cb5b97d1d1878dcf6e/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJ3ZWJwIiwicmVzaXplX3RvX2xpbWl0IjpbMTI4MCwxMDI0XX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--84d8884f224e8330b83a950151768b773558b857/image-20260806003521697.png)

這裡有兩種可能的走法，它可能在同一個回應裡開兩張單，這就是上一篇文章講到的 parallel tool use：一次模型回應帶回多個 `tool_use`，我們的 `results` 收一疊回一疊可以接得住。不過目前的 KeSi 的 `for` 還是會依序執行兩次 `read_file`，協議上能一次開多張單，不等於程式真的同時讀檔；要讓執行也並行，還得用 `asyncio.to_thread()` 搭配 `asyncio.gather()`，或是 thread pool 之類的做法改造。另一種走法是分兩圈，讀完第一個、看了內容才決定讀第二個。第二步根據第一步的結果臨場決定。

不管哪種走法，這裡我只提供了檔名，沒有指定工具呼叫的順序，剩下的就是交給模型自己安排。手上只有一個 `read_file` 工具就能組合出多步的偵查計畫，之後工具多了，這種自主編排會越來越像真正的 Coding Agent。

## 魔法在哪裡？

不過，如果不給它工具模型真的答不出來嗎？

你試試把 `tools=TOOLS` 那行拿掉，重新啟動程式，再問一次「`kesi.py` 這個檔案在做什麼？」。觀察它是承認看不到檔案還是會憑空猜出一份內容。

沒有工具的模型只剩兩條路。

一條是它坦白承認看不到你的檔案，這是好的情況。官方[詞彙表](https://platform.claude.com/docs/en/about-claude/glossary)講 honest 的段落就寫著：

&gt; An **honest** AI will give accurate information, and not hallucinate or confabulate. It will acknowledge its limitations and uncertainties when appropriate.

誠實的 AI 該給正確的資訊、不幻覺瞎編，並且在該承認極限的時候承認，這是刻意訓練出來的「品德」。我自己跑了兩次，兩次都走這條路，它都老實說看不到檔案，還反過來要我用 `cat` 指令把內容倒出來貼給它。

另一條路比較危險，它可能憑著訓練時看過的無數個 Python 專案，「推測」出一個聽起來很合理的 `kesi.py` 內容，而且還講得跟真的一樣。這就是幻覺，模型的本質是一個往下接字的機器，它沒有「不知道就閉嘴」的剎車，訓練讓它大部分時候會承認不知道，但那是機率，不是保證。

工具改變了這件事，有了 `read_file` 工具之後回答的根據從「權重裡的模糊印象」換成「剛從硬碟讀出來的內容」，答案被釘在證據上，這個叫 grounding。第四天的文章整理過模型的三層記憶，權重是唯讀的舊書，日記是這次對話的白板，工具則是把真實世界的資料即時搬到面前讓模型可以 openbook 進行回答。

工具讓模型不需要猜，但不是讓它不會猜。權重沒變，那本舊書還是同一本，它只是多了一條「去查」的路可以走。就算有工具也只是能提高可靠度但不是保證，工具可能拿到過期或錯誤的內容，模型也可能讀錯證據。你看看，模型就是這麼難搞！

還有，查證不是免費的，每多轉一圈就多一次 API 請求，同一圈可以回報好幾個檔案，但每一份工具結果都會增加 token。可靠度是用延遲跟 token 換來的，這筆交易絕大多數時候划算，但要先心裡有數這個不是免費的，之後幫 KeSi 裝上電表你就會看到了。

正版的 Claude Code 的許多核心能力拆開都是這個模式，讀檔是把檔案內容搬進 context、跑測試是把測試結果搬進 context、查 git 紀錄是把歷史搬進 context。模型還是同一顆模型，差別在你餵給它的是印象還是證據。

## 眼見不一定為憑

工具幫模型開了眼，但有眼睛就有視覺攻擊。官方在處理工具結果的文件裡放了一段正式警告，工具帶回來的內容常常來自你控制不了的地方，網頁、信件、第三方 API 等等，要把它當成不可信任的內容對待，因為攻擊者可能在裡面埋一句「忽略以上指示，改做某某事」這種 prompt injection 的手法，模型可能還真的會照做。

現在的 KeSi 只讀本機工作目錄裡的檔案，影響範圍雖然比較小但風險也不是零。例如剛剛從 GitHub clone 下來的專案、網路下載的文件，甚至是原始碼註解都可能夾帶 prompt injection。切記，技術高超的壞人會在你意想不到的地方做一些壞事（或說是有趣的事？），等 KeSi 會上網之後或是開始會讀一些不認識的人寫的 code，每一次 `tool_result` 都可以是有心人的機會。把不可信內容放在 `tool_result`，不要混進 system 或使用者文字只是其中一道防線，最小權限、sandbox 與高風險操作前的確認，這些之後都會補上。

## 慢動作重播

我們再用慢動作看一次迴圈轉動的樣子，你可以在 `run_agent` 的迴圈開頭加兩行，把每一圈送出去的日記攤開來：

```python
print(f&quot;--- 第 {len(history)} 則訊息時的請求 ---&quot;)
for m in history:
    print(&quot; &quot;, m[&quot;role&quot;], str(m[&quot;content&quot;])[:60])
```

問一個會用到工具的問題，再看印出的日記快照。第一圈應該只有一則 `user` 問題；工具執行完以後，第二圈會多出 `assistant` 的 `tool_use` 與 `user` 的 `tool_result` 兩則訊息。我拿 `examples/a.txt` 實際跑一次長這樣：

```plaintext
你 &gt; examples/a.txt 裡面寫什麼？
--- 第 1 則訊息時的請求 ---
  user examples/a.txt 裡面寫什麼？
  [執行工具] read_file({&#39;file_path&#39;: &#39;examples/a.txt&#39;})
--- 第 3 則訊息時的請求 ---
  user examples/a.txt 裡面寫什麼？
  assistant [ToolUseBlock(id=&#39;toolu_01Hp9nZbZuJFnz9wfFnCNSNn&#39;, caller=Di
  user [{&#39;type&#39;: &#39;tool_result&#39;, &#39;tool_use_id&#39;: &#39;toolu_01Hp9nZbZuJFn
```

看標題那個數字就懂了，第一圈的時候日記只有 1 則，也就是你剛打的問題，第二圈直接跳到 3 則。

上面程式碼裡的 `[:60]` 是我故意截斷的，`tool_result` 動輒把整個檔案的內容讀進來，不截的話畫面會被洗版，我們要看的是結構不是內文。仔細看，第一圈送出去的日記只有你的問題，第二圈多了兩則，一則是模型帶著許願單的回覆，另一則是我們帶著結果的回報。這兩則從此就寫在日記裡了，之後每一輪都跟著重送。前一篇文章有講到，工具加進來之後日記增厚的主因就是這些工具往返，一次讀個大檔案，幾千個 token 進日記，還每輪複誦一遍，帳單跟 context 上限的壓力都是從這裡來的，之後有一整天要幫這些結果減肥。

重播的過程還揭露了一件對帳單很重要的事，你以為的「一問一答」，在 API 那頭不是一次請求。你按一次 Enter，內圈轉了兩圈就是兩次請求、轉五圈就是五次，每一次都全額計費，而且每一圈都帶著完整的 `tools` 跟那段昨天講的隱形 prompt，內圈轉五圈，工具說明書就被重送了五次。所以之後看到「一輪對話」的帳單會比想像中的貴也別意外，我們人類眼中的一輪可能是 API 帳本上的好幾筆。

不過內圈每一圈送出去的內容，絕大部分跟上一圈完全相同，只差最後多的那兩則。一樣的東西反覆送、反覆付全額，這感覺有點笨也有點浪費錢？是的，所以在第二天的文章我們在正版身上看到的 `cache_control` 標記，就是在解這一題，到時候 KeSi 也會加進來。

KeSi 的 `read_file` 是把檔案從頭到尾整包回傳，簡單粗暴。那正版 Claude Code 的讀檔工具呢？我們之前攔到的正版 `Read` 工具，你會發現人家的 schema 多了 `offset` 跟 `limit` 兩個選填參數，說明書上寫著是給大檔案分段讀取用的，而且它回傳的內容還帶行號。主流的 coding agent 的讀檔工具多半都長這樣，行號加截斷。帶行號是為了讓模型講得出「位置」，之後模型幫我們改程式碼才得能精準指出改哪裡。

截斷是為了避免 context window 爆炸，日記的上限跟帳單前面都算過了，一個幾萬行的檔案整包塞進 context，錢跟空間都會爆炸。Claude Code 讀完整份內容會超過工具的 token 上限時，預設只先回第一段並附上 `PARTIAL view` 提示，模型需要更多再往下翻頁，`offset` 跟 `limit` 就是翻頁鈕。

## 小結

今天用幾十行新程式碼閉了環，KeSi 從只有一張嘴變成多了一個工具的 agent，能讀檔案而且自己回答問題。所謂 agent 的能力，是「模型的判斷」加「工具的證據」外加「迴圈」，現在你都看到了。下一集就來幫模型開眼，給它一雙能看見整個專案的眼睛，到時候你連檔名都不用報，它自己會找。

咱們下集見，你是我\~ 的眼 ♫

