回顧一下 chat() 要做到的:
main.py 取得輸入,加入多輪歷史。thinking_finish 來做推理總結,顯示推理耗時)。main.py 發送審核,並請求 tool.py 執行 execute_tool()。上一篇有說過,雖然 ollama.chat() 本身就自帶了 tools 這個參數來做工具傳入,並且會自行解析調用工具,但因為會有顯示問題,所以我採用「system prompt 傳入工具 + 自行解析調用標籤」的方式來提升效率。
這裡,我們直接拿 _extract_safe_text() 處理好的 completed_tools 來用,
然後要注意的是,最後一輪(模型內部迴圈達到 self.max_turns 設定次數)時,就不對調用標籤做解析,畢竟我們不允許模型調用了,就算他偷偷調用,我們也要在此處做攔截,也就是讓 tools_to_execute 為空串列。
# src/meowgent/agent.py
...
class Agent():
...
def chat(...) -> Iterator[LLMResponse]:
...
while turns < self.max_turns:
...
for chunk in response:
...
tools_to_execute = completed_tools if not is_last_turn else []
至於為什麼 raw_json 要特別強調出 raw 呢?沒錯因為我們還要對其做處理。
模型被要求輸出 JSON 格式時往往會反射性地用 ```json ... ``` 包起來,也就是 Markdown 的程式碼區塊(因為訓練時看過數百億行 Markdown 文件。這讓模型養成了極強的肌肉記憶),為了防止這種現象造成後續無法成功從中提取出要調用的工具函數和參數,我們需要事先手動將其刪去。
這裡,使用的是 re.sub() 將指定目標做替換為空白字串,語法為:
r"" 表示 raw,不把反斜線當作跳脫字元(Python 原生)。\s 表示空白字元,這裡說的是換行縮排的空白。* 代表可以有很多個(很多個換行和縮排的空白)。| 表示「或」。^ 表示開頭;$ 表示結尾(搭配 re.MULTILINE (Flags 參數)變為每一行的開頭結尾都適用)。r" ^```json\s* | ^```\s* | ```$ "
─────┬───── ─ ───┬─── ─ ──┬─
│ │ │ │ │
① 帶標籤的開頭 或 ② 純反引號開頭 或 ③ 閉合結尾
(行首的 ```json) (行首的 ```) (行尾的 ```)
分別對應到「開頭為 ```json」、「開頭為 ```」以及「結尾為 ``` 」三種情況。
# src/meowgent/agent.py
...
class Agent():
...
def chat(...) -> Iterator[LLMResponse]:
...
while turns < self.max_turns:
...
if tools_to_execute:
for raw_json in tools_to_execute:
try:
# 容錯清理 markdown 程式碼區塊符號(如 ```json ... ```)
cleaned_json = RX_CODE_BLOCK.sub("", raw_json,).strip()
有沒有發現個奇怪的地方?剛剛說的不是
re.sub()嗎?怎麼變成RX_CODE_BLOCK.sub這鬼東西?
還記得很久以前(Day 3 的時候)有說過正則的「預編譯」嗎?沒錯這裡就是用了預編譯功能!
我們先來看看如果沒有使用的話,代碼應該長什麼樣:... = re.sub(r"^```json\s*|^```\s*|```$", "", raw_json, flags=re.MULTILINE).strip()而我們把這整個正則搬到類別最外面去做預編譯:
# src/meowgent/agent.py from ... ... RX_CODE_BLOCK = re.compile(r"^```json\s*|^```\s*|```$", flags=re.MULTILINE) RX_GET_JSON = re.compile(r'"name"\s*:\s*"([^"]+)"') # 等等後面還有一個正則,先一起編譯,等下再來解釋 ... class Agent(): ...
到了這裡我們又要建立新的 data class - ToolCall:
...
@dataclass
class ToolCall:
tool_name: str
args: Dict[str, Any] # 要傳入工具函數的參數
提取出了乾淨的格式,接下來就能將其轉為真正的 JSON 形式(用 json.loads())並開始讀取工具了:
轉換後我們用 ToolCall 將工具名和參數打包裝進 t,
接下來,我們拿從 tool.py 來的註冊表 TOOL_REGISTRY 查詢用屬性裝飾器加上的 need_approval 屬性,也就是看看是否需要審核。
再來,如果「不需審核」或者「審核通過(tool_approval() 返回 True)」時用 self.executor.submit() 進行「多執行緒並發執行工具」,再和 t 存放在元組內(為什麼這樣放,後面會詳細解釋)。
tools_result.append( ( t , self.executor.submit(execute_tool, t.tool_name, t.args) ) )
──┬─ ───────────────────────────┬───────────────────────────
│ │
① 工具資料 ② 背景派工(拿到 Future 期票)
└─────────────────────┬─────────────────────┘
③ 打包成 Tuple 存進清單
多執行緒並發執行工具用法為:
.submit(執行目標函數, *args, **kwargs),執行結果後續用.result()取出。
而如果「沒有通過上面的條件」,也就是走到了 else: 就代表審核被駁回了,不能使用此工具,一樣用元組包起,前面為 t 後面為拒絕的訊息。
# src/meowgent/agent.py
...
class Agent():
...
def chat(...) -> Iterator[LLMResponse]:
...
while turns < self.max_turns:
...
if tools_to_execute:
for raw_json in tools_to_execute:
try:
cleaned_json = ...
call_data = json.loads(cleaned_json)
t = ToolCall(
tool_name=call_data["name"],
args=call_data.get("arguments", {})
)
need_approval = getattr(
TOOL_REGISTRY.get(t.tool_name, None),
"need_approval",
True
)
if not need_approval or tool_approval(t.tool_name, t.args):
tools_result.append(
(
t,
self.executor.submit(
execute_tool,
t.tool_name,
t.args
)
) # 元組
)
else:
tools_result.append(
(
t,
"[系統提示] 使用者基於安全考量拒絕了此工具的執行"
) # 元組
)
而下面為了捕捉 JSON 解析錯誤(json.loads 失敗):
要先用 re.search 搜尋第一個搜到的 "name": " " 之中的內容(工具名) ,Regex 語法為:
[] 是字元組合,如 [abc] 代表只要 a、b、c 其中有一個就行。():擷取群組(Group 1),代表我們要抓取的工具名稱,用 match.group(1) 取出。match.group(0) 則是匹配到的完整字串(例如 "name": "read_file")。^ 放在 [] 裡表示否定。[^"]代表除了雙引號以外的任何字元。+ 表示貪婪搜尋,也就是 " " 中間的內容都截取下來。r' "name" \s*:\s* " ( [^"]+ ) " '
───┬── ───┬─── ─ ────┬─── ─
│ │ │
① 找 "name" ② 冒號 ③ 抓取引號中間的文字(Group 1)
用單引號包起字串是因為搜尋裡有雙引號,如果用雙引號包的話,要用
\做跳脫。
截取下工具名,用 ToolCall 打包,一樣用元組的方式加入 tools_result。
# src/meowgent/agent.py
...
class Agent():
...
def chat(...) -> Iterator[LLMResponse]:
...
while turns < self.max_turns:
...
if tools_to_execute:
for raw_json in tools_to_execute:
...
try:
...
except Exception as e:
name_match = RX_GET_JSON.search(raw_json) # 同樣使用預編譯
tool_name = name_match.group(1) if name_match else "unknown"
err_tool = ToolCall(tool_name=tool_name, args={})
tools_result.append(
(
err_tool,
f"
[系統提示] 工具調用格式解析錯誤:
{e}。請確保 <tool_call> 內嚴格為合法 JSON 格式。
"
) # 元組
)
這裡分為有無工具調用兩種情況討論:
首先,我們先將模型做出的回答(result_full_text),也就是模型的工具調用請求加入到多輪紀錄。
我現在幫你讀取:<tool_call>{"name": "read_file", ...}</tool_call>把這部分存起來。
還記得我們前面傳給模型的
temp_history_messages嗎?這裡我們要傳入的不是他喔!前面傳入他是為了後續加入多輪時不要把「最後一次工具調用的提醒」加入到history_messages裡。
# src/meowgent/agent.py
...
class Agent():
...
def chat(...) -> Iterator[LLMResponse]:
...
while turns < self.max_turns:
...
if tools_to_execute:
...
if tools_result: # 表示有 tool use 需求
self.history_messages.append({
"role": "assistant",
"content": result_full_text
})
接下來,就要來調用工具了,使用到先前 tools_result 串列中存放的元組,將裡面的 ToolCall 與 Future 物件用 for 取出。
至於為什麼要用元組?
其實這裡用串列也是可以的,只是在習慣上,「異種類型、固定長度的成對關係(Record / Pair)」會用元組,而「同種類、長度會隨時增減」就用串列。
所以ToolCall跟Future物件(或失敗時的字串)放在元組,而打包起的元組再放tools_result串列。
接著檢查是否為 Future 物件(用 hasattr(tool_v, "result") 檢查是否具備 .result 屬性),有則呼叫 .result() 並標記成功調用,無(格式錯誤或審核未通過)則回傳為字串的 tool_v 。
得到結果後把結果放入 history_messages 裡,並回傳 LLMResponse。
# src/meowgent/agent.py
...
class Agent():
...
def chat(...) -> Iterator[LLMResponse]:
...
while turns < self.max_turns:
...
if tools_result:
...
for t, tool_v in tools_result:
# t 為 ToolCall
# tool_v 為 Future 物件(執行中任務)或 str(被拒絕/出錯的提示字串)
if hasattr(tool_v, "result"): # 檢查是否有 .result() 可用
result = tool_v.result()
is_success = True
else:
result = tool_v
is_success = False
self.history_messages.append({
"role": "user",
"content": f"
<tool_response>
\n[工具 {t.tool_name} 執行結果]:\n{result}\n
</tool_response>
"
})
status = "tool_executed" if is_success else "tool_rejected"
yield LLMResponse(status=status, tool_name=t.tool_name)
沒有工具調用的情況就簡單很多了,
先確定模型有回傳內容(沒有要加上提示),接著加入多輪紀錄,最後要退出 while turns < self.max_turns: 迴圈:
# src/meowgent/agent.py
...
class Agent():
...
def chat(...) -> Iterator[LLMResponse]:
...
while turns < self.max_turns:
...
if tools_result:
...
else: # 沒有 tool use 需求,生成最終回答
if not result_full_text.strip():
result_full_text = "(模型沒有回傳內容)" # 萬一沒有回傳東西回傳提示
self.history_messages.append({
"role": "assistant",
"content": result_full_text
})
break
# 模型沒有調用工具 -> 表示已經生成最終回答,故退出 while turns < self.max_turns: 迴圈
我們要為模型加入「溫度係數」、「上下文上限」
這裡,messages 要改為 ollama_history_messages,還加入了 options 參數,裡面放了:
"num_ctx" 自行設定上下文的 token 數,依照自己的設備性能做決定,預設情況下只有 2048 token,少得可憐。超過的話,會從最開頭的紀錄開始刪,未來加入的 system prompt 因為
"role"設定的是"system",不會被刪去(除非 system prompt 本身就超過了上下文限制)。
這裡我設定的是 $2^{14}$,至於為什麼要用 2 的次方呢?
這是因為在電腦與 GPU 的底層世界中,所有記憶體位址和矩陣運算全部都是以 2 的次方進行對齊的,可以直接理解為用 2 的次方來進行計算是最符合效率的。
temperature 是回答隨機性,代表著模型的性格。| 溫度數值(Temperature) | 模型性格表現 | 適用場景 |
|---|---|---|
0.0 ~ 0.2 (目前專案設的 0.1) |
冷靜嚴謹、一絲不苟、嚴格遵守格式。 永遠挑選機率最高、最穩妥的字,幾乎零隨機性。 | 寫程式(Coding)、呼叫工具(Agent)、數學邏輯 |
0.7 ~ 0.8 (官方預設值) |
自然流暢、略帶靈活。 像一個正常人在跟你聊天。 | 一般日常問答、文章翻譯、客服對話 |
1.2 ~ 1.5 |
天馬行空、創意發散。 容易腦洞大開,但也容易胡言亂語(幻覺)。 | 寫小說、寫詩、廣告文案發想 |
# src/meowgent/providers/ollama_provider.py
...
class OllamaProvider(...):
...
def stream_generate(...) -> Iterator[StreamChunk]:
...
response = ollama.chat(
model=self.model_name,
messages=ollama_history_messages,
stream=True,
options={
"num_ctx": 16384,
"temperature": 0.1 # 降低隨機性
}
)
而你可能又注意到,怎麼沒有工具調用的 tools 參數呢?
這裡沒有寫錯,先前一直有提到,為了解決顯示問題,我不使用官方寫好的方式。
經歷了 Day 5、Day 6、Day 7,作為整個軟體的核心大腦 - agent.py 已經大功告成啦!具備了完整的思考迴圈、工具調用機制。
而我們的 provider 也做了小小的升級,
下一篇,我們將介紹如何把 Python 工具轉換為模型能理解的 XML 格式與 Prompt。