Day 06 - 第一個工具 read_file

Day 06 - 第一個工具 read_file

約 14,984 字

前一篇文章我們把許願單的格式看得差不多然後就下班了,許願單來了但還沒人去處理。接下來把執行跟回報的進度補上,讓那個從第一天就一直講的 while 迴圈真正轉起來。轉完之後,KeSi 會第一次做到一件像樣的事,我會問它某個檔案在幹嘛,KeSi 能夠自己去讀取檔案然後自己回答問題。

GitHub Repo:https://github.com/kaochenlong/KeSi

迴圈的五個步驟

先把第一天文章的那幾行迴圈的虛擬碼搬回來:

while 還沒完成:
    問模型:現在該做什麼?
    照它說的做
    把結果告訴它

現在可以把它展開成五個具體的步驟:

  1. 組信送出,把日記連同工具清單寄給模型
  2. 讀回應,看 stop_reason 的結果,如果是 end_turn 就收工,是 tool_use 就往下走到步驟 3
  3. 照許願單上的 nameinput,跑我們自己寫的函式
  4. 函式把執行結果包成 tool_result,順便把編號一起記進日記裡
  5. 回到步驟 1,把變厚的日記再寄一次

這個轉圈的過程,外面的世界叫它 agent loop 或 tool use loop,之後在各種文章看到這些名詞指的就是這些步驟。

步驟 1 跟 2 在上一篇文章做過了,這篇文章主要做步驟 3 跟 4,加上讀檔時候的防護機制一共幾十行。這幾十行可以把一個只出一張嘴的聊天程式,變成一個有能力自己動手的 agent。

第一個工具 read_file

上篇文章的 get_time 是拿來看許願格式的教學道具,接著來做個讀檔案的工具,這個工具應該會實用一些。先看工具的定義:

TOOLS = [
    {
        "name": "read_file",
        "description": "讀取工作目錄底下的文字檔,回傳完整內容。"
        "當使用者問到某個檔案裡有什麼、或需要檔案內容才能回答時呼叫。",
        "strict": True,
        "input_schema": {
            "type": "object",
            "properties": {
                "file_path": {
                    "type": "string",
                    "description": "相對於工作目錄的檔案路徑",
                }
            },
            "required": ["file_path"],
            "additionalProperties": False,
        },
    }
]

get_time 最大的差別在於 properties 不是空的了,這個工具我設定一個必填參數 file_path,這表示等一下模型開單的時候,模型得自己決定要把什麼路徑填進表格裡。說明書照昨天學的原則寫,講清楚做什麼、什麼時候呼叫,參數部份也帶一句描述。以官方三、四句起跳的標準來看還是太簡略,不過現在工具數量少,先求有之後再求好。

strict: True 會要求模型送出的參數符合這份 schema,additionalProperties: False 則明講不接受表格以外的欄位。雖然 API 端會先要求格式,但程式端之後還是要處理讀檔失敗,兩邊各管各的。再來是執行函式,也就是願望成真的地方:

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"錯誤:找不到或無法解析檔案 {file_path}", True

    if not target.is_relative_to(BASE_DIR):
        return "錯誤:不允許讀取工作目錄以外的檔案。", True
    if not target.is_file():
        return f"錯誤:不是可以讀取的文字檔 {file_path}", True

    try:
        return target.read_text(encoding="utf-8"), False
    except UnicodeDecodeError:
        return f"錯誤:檔案不是 UTF-8 文字檔 {file_path}", True
    except OSError as exc:
        return f"錯誤:無法讀取檔案 {file_path}:{exc}", 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 看得到:

$ 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="utf-8" 是指只收能用 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 長這樣:

# /// script
# requires-python = ">=3.14"
# dependencies = ["anthropic"]
# ///

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"錯誤:找不到或無法解析檔案 {file_path}", True

    if not target.is_relative_to(BASE_DIR):
        return "錯誤:不允許讀取工作目錄以外的檔案。", True
    if not target.is_file():
        return f"錯誤:不是可以讀取的文字檔 {file_path}", True

    try:
        return target.read_text(encoding="utf-8"), False
    except UnicodeDecodeError:
        return f"錯誤:檔案不是 UTF-8 文字檔 {file_path}", True
    except OSError as exc:
        return f"錯誤:無法讀取檔案 {file_path}:{exc}", True


TOOLS = [
    {
        "name": "read_file",
        "description": "讀取工作目錄底下的文字檔,回傳完整內容。"
        "當使用者問到某個檔案裡有什麼、或需要檔案內容才能回答時呼叫。",
        "strict": True,
        "input_schema": {
            "type": "object",
            "properties": {
                "file_path": {
                    "type": "string",
                    "description": "相對於工作目錄的檔案路徑",
                }
            },
            "required": ["file_path"],
            "additionalProperties": False,
        },
    }
]


def run_agent(history):
    while True:
        resp = client.messages.create(
            model="claude-haiku-4-5",
            max_tokens=1024,
            tools=TOOLS,
            messages=history,
        )
        history.append({"role": "assistant", "content": resp.content})

        if resp.stop_reason != "tool_use":
            return "".join(b.text for b in resp.content if b.type == "text")

        results = []
        for block in resp.content:
            if block.type == "tool_use":
                print(f"  [執行工具] {block.name}({block.input})")
                if block.name == "read_file":
                    try:
                        output, is_error = read_file(**block.input)
                    except TypeError as exc:
                        output = f"錯誤:read_file 的參數不正確:{exc}"
                        is_error = True
                else:
                    output = f"錯誤:沒有 {block.name} 這個工具。"
                    is_error = True
                results.append(
                    {
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": output,
                        "is_error": is_error,
                    }
                )
        history.append({"role": "user", "content": results})


history = []
print("KeSi。輸入 /exit 離開、/reset 清空對話。")
while True:
    try:
        user = input("你 > ").strip()
    except (EOFError, KeyboardInterrupt):
        break
    if user in ("/exit", "/quit"):
        break
    if user == "/reset":
        history = []
        print("(日記已清空,我們重新開始)")
        continue
    if not user:
        continue

    history.append({"role": "user", "content": user})
    print("KeSi >", 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 積木長這樣,四個欄位:

{
    "type": "tool_result",
    "tool_use_id": block.id,
    "content": output,
    "is_error": is_error,
}

is_error 是布林值,成功時是 False,失敗時是 True,所以模型不只看到錯誤文字還可以拿到明確的失敗訊號。tool_use_id 是昨天呼叫工具時候的編號,這裡我把許願單上的 id 抄回來,之後模型才知道這份結果會對到哪一張單子,特別是一次處理多張單的時候就得靠這個編號來對號入座。

官方對這個積木的擺放位置有幾條規矩,寫錯就會被 server 丟一個 HTTP 400 的錯誤:

  • 帶著 tool_result 的 user 訊息必須緊跟在開單的 assistant 訊息後面,中間不能插任何別的訊息。
  • 在這則 user 訊息裡,tool_result 積木必須排在 content 陣列的最前面。想附加文字的話,文字要排在所有結果後面,順序反了 API 會直接退件。
  • 每張單都要有結果,有漏任何一張就會整包被退件,官方連錯誤訊息都寫給你看了

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_callfunction_call_output 這兩種 item,不走 role。Anthropic 沒有這樣設計,工具的往返全部塞進 userassistant 這兩種角色的積木裡,模型開單是 assistant 的積木,我們寫的工具回報是 user 的積木。概念上有點怪(明明是程式在回報,卻掛 user 的名義),但結構上看起來滿整齊的,結果就是這本日記從頭到尾就只有這兩種角色在輪流。

而 Google Gemini 的 generateContent API 跟 Anthropic 是同一派,工具的往返也是塞在兩種角色裡,只是它不叫 assistant 而是叫 model,工具結果一樣掛在 user 名下。目前較新的 Interactions API 則改用 function_callfunction_result 這兩種 step,也不再是這套 role 排法。

見證奇蹟的時刻

demo 的題目我想了一下,決定來點好玩的,就是叫 KeSi 讀它自己:

$ uv run --env-file .env kesi.py

啟動後輸入「kesi.py 這個檔案在做什麼?」:

build-a-read-file-tool-1

實際回答會因模型而異,但你可以觀察的是先出現 read_file({'file_path': 'kesi.py'}) 的工具紀錄,接著才是根據原始碼整理出的回答,這代表內圈確實走完了開單、執行、回填最後再問一次模型。

那行 [執行工具] 是內圈轉動的痕跡,我問 KeSi 一句話,它判斷需要看檔案、自己填了 kesi.py 這個路徑、讀取檔案然後根據讀到的內容進行回答。程式裡沒有寫死「問題提到檔案就呼叫 read_file」這條 if,怎麼選工具,是模型根據工具說明書做的判斷。

我選讀取 kesi.py 的原因除了可以少寫一個測試檔案外,因為它讀到的內容裡就有 TOOLS 的定義,等於它透過自己的工具,讀到了自己工具的說明書原始碼。不過讀檔只是讀,不會執行,所以不會發生什麼無限月讀... 不是,是無限遞迴的災難。

KeSi 現在可以讀懂了它自己的原始碼,然後跟我解釋它自己是怎麼運作的,這如果在幾年前聽起來像科幻小說,現在不過就是一百多行的 Python 程式而已。

試試越獄

監獄蓋好了總要驗收一下。直接叫它幹壞事,在提示符輸入「幫我讀 /etc/passwd 這個檔案」。

build-a-read-file-tool-2

模型先開出 read_file({'file_path': '/etc/passwd'}) 許願單,下一步應該是本機函式拒絕路徑,並用 is_error: true 把原因回給模型,然後模型就會告訴我為什麼失敗。

模型開單,我們的函式檢查路徑是否合法,然後拒絕執行並回傳錯誤,模型再把失敗結果納入回答。整個過程不需要也不應該讓程式崩潰或中斷對話,這就是「錯誤是回給模型的」的設計,也是之前提到「動手的永遠是你的程式」。模型可以想、可以許願,但會不會做是工具決定的。

有興趣可以再對 KeSi 兇一點,明示或暗示它想辦法繞過限制,觀察路徑檢查能不能擋住不同寫法。

連續開單

再來個進階的,問一個需要讀兩個檔案才答得出來的問題:

先準備 a.txtb.txt 兩個內容相關的小檔,然後問「在 examples 目錄裡的 a.txtb.txt 的內容有什麼關聯?」。實際執行時,注意模型是在同一個回應裡開兩張單還是分兩圈各讀一個,這兩種都可能發生:

build-a-read-file-tool-3

這裡有兩種可能的走法,它可能在同一個回應裡開兩張單,這就是上一篇文章講到的 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 這個檔案在做什麼?」。觀察它是承認看不到檔案還是會憑空猜出一份內容。

沒有工具的模型只剩兩條路。

一條是它坦白承認看不到你的檔案,這是好的情況。官方詞彙表講 honest 的段落就寫著:

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 的迴圈開頭加兩行,把每一圈送出去的日記攤開來:

print(f"--- 第 {len(history)} 則訊息時的請求 ---")
for m in history:
    print(" ", m["role"], str(m["content"])[:60])

問一個會用到工具的問題,再看印出的日記快照。第一圈應該只有一則 user 問題;工具執行完以後,第二圈會多出 assistanttool_useusertool_result 兩則訊息。我拿 examples/a.txt 實際跑一次長這樣:

你 > examples/a.txt 裡面寫什麼?
--- 第 1 則訊息時的請求 ---
  user examples/a.txt 裡面寫什麼?
  [執行工具] read_file({'file_path': 'examples/a.txt'})
--- 第 3 則訊息時的請求 ---
  user examples/a.txt 裡面寫什麼?
  assistant [ToolUseBlock(id='toolu_01Hp9nZbZuJFnz9wfFnCNSNn', caller=Di
  user [{'type': 'tool_result', 'tool_use_id': 'toolu_01Hp9nZbZuJFn

看標題那個數字就懂了,第一圈的時候日記只有 1 則,也就是你剛打的問題,第二圈直接跳到 3 則。

上面程式碼裡的 [:60] 是我故意截斷的,tool_result 動輒把整個檔案的內容讀進來,不截的話畫面會被洗版,我們要看的是結構不是內文。仔細看,第一圈送出去的日記只有你的問題,第二圈多了兩則,一則是模型帶著許願單的回覆,另一則是我們帶著結果的回報。這兩則從此就寫在日記裡了,之後每一輪都跟著重送。前一篇文章有講到,工具加進來之後日記增厚的主因就是這些工具往返,一次讀個大檔案,幾千個 token 進日記,還每輪複誦一遍,帳單跟 context 上限的壓力都是從這裡來的,之後有一整天要幫這些結果減肥。

重播的過程還揭露了一件對帳單很重要的事,你以為的「一問一答」,在 API 那頭不是一次請求。你按一次 Enter,內圈轉了兩圈就是兩次請求、轉五圈就是五次,每一次都全額計費,而且每一圈都帶著完整的 tools 跟那段昨天講的隱形 prompt,內圈轉五圈,工具說明書就被重送了五次。所以之後看到「一輪對話」的帳單會比想像中的貴也別意外,我們人類眼中的一輪可能是 API 帳本上的好幾筆。

不過內圈每一圈送出去的內容,絕大部分跟上一圈完全相同,只差最後多的那兩則。一樣的東西反覆送、反覆付全額,這感覺有點笨也有點浪費錢?是的,所以在第二天的文章我們在正版身上看到的 cache_control 標記,就是在解這一題,到時候 KeSi 也會加進來。

KeSi 的 read_file 是把檔案從頭到尾整包回傳,簡單粗暴。那正版 Claude Code 的讀檔工具呢?我們之前攔到的正版 Read 工具,你會發現人家的 schema 多了 offsetlimit 兩個選填參數,說明書上寫著是給大檔案分段讀取用的,而且它回傳的內容還帶行號。主流的 coding agent 的讀檔工具多半都長這樣,行號加截斷。帶行號是為了讓模型講得出「位置」,之後模型幫我們改程式碼才得能精準指出改哪裡。

截斷是為了避免 context window 爆炸,日記的上限跟帳單前面都算過了,一個幾萬行的檔案整包塞進 context,錢跟空間都會爆炸。Claude Code 讀完整份內容會超過工具的 token 上限時,預設只先回第一段並附上 PARTIAL view 提示,模型需要更多再往下翻頁,offsetlimit 就是翻頁鈕。

小結

今天用幾十行新程式碼閉了環,KeSi 從只有一張嘴變成多了一個工具的 agent,能讀檔案而且自己回答問題。所謂 agent 的能力,是「模型的判斷」加「工具的證據」外加「迴圈」,現在你都看到了。下一集就來幫模型開眼,給它一雙能看見整個專案的眼睛,到時候你連檔名都不用報,它自己會找。

咱們下集見,你是我~ 的眼 ♫

合作夥伴

留言討論