iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0

在這篇文章中,我們要處理一個較為棘手的任務:從流式輸出的模型回覆(chunk.content_chunk)中,精準「分離出工具調用請求」。

你可能覺得很奇怪,回答就回答,怎麼還混進了工具調用呢
如果是最單純的調用方式(如下所示),直接交由 Ollama SDK 處理,它會自動幫我們完成工具分離的動作:

response = ollama.chat(
	tools=tools_list,
	...
)

然而,當模型生成那一長串工具調用的 JSON 格式時,SDK 沒辦法把內容流式的交到 response,就導致了一旦要調用工具時,畫面都會有數秒甚至數十秒的停頓,最後才顯示出工具已被調用。
工具需要的參數越長,停頓也就會越長,像是要請模型寫出一篇冗長的文件時,畫面就會卡住非常久。
所以麻煩了點,我們自行處理「分離工具調用」、「工具調用解析」兩個部分,今天來搞定前者!


訂製分離函數

我們要來製作一個函數,傳入模型輸出的串流內容,解析並輸出「模型回答內容」、「還未完整的工具調用」、「已完整的工具調用」。

這裡的完不完整指的是是否出現了 </tool_call>,還沒出現前表示還沒輸出完成。

回答內容跟工具分離可能還好理解,那為什麼工具還要分是否完整呢?
前面有說過,在輸出工具調用時,我們會對其進行顯示,而若已經完成輸出了,就不需要進行顯示了,要進入下方的邏輯進行工具的解析。這就是需要分流的原因。


初始化

我們在 Agent() 類別外建立函數:
這個函數接收目前為止累積的模型全文(full_text),並透過迴圈與底層演算法加持的 find(),在全文中搜尋是否出現 <tool_call>

這裡的演算法是 Fastsearch ,它非常厲害,不需要把每個字都遍歷,就能搜尋到目標索引。

搜索方式是用「跳的」,從目標樣式的最後一個字元比對,具體過程如以下解釋:
目標樣式為 <tool_call>長度 11

  1. 起始索引為 0,樣式涵蓋索引 0 ~ 10,看到索引 10 為「要」,而不是「>」,往後跳 11 格(新起始索引為 0 + 11 = 11)。
  2. 起始索引 11,涵蓋索引 11 ~ 21,索引 21 依然不是「>」,而是「c」,但是 c 「有出現在樣式中」,對應到的「樣式內索引」為 6,
    演算法將這兩個 c 做對齊,再移動 10 - 6 = 4 格,新索引來到 11 + 4 =15
  3. 起始索引 15,涵蓋 15 ~ 25,索引 25 剛好是「>」,找到後從尾部依序向前核對目標樣式和文本是否完整對齊
  4. 確認完整對齊,得到答案為目標起始索引為 15。
文本:關 於 這 個 問 題 ,這 裡 需 要 調 用  工 具  < t  o  o  l  _  c  a  l  l  >
索引: 0 1 2  3  4  5 6 7  8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25

樣式:< t o o l _ c a l l  >
索引:0 1 2 3 4 5 6 7 8 9 10
# src/meowgent/agent.py
from ...
from typing import Tuple, Optional, List

def _extract_safe_text(
    full_text: str,
    start_tag: str = "<tool_call>",
    end_tag: str = "</tool_call>"
) -> Tuple[str, Optional[str], List[str]]:
	
	safe_parts = [] # 已經提取出的模型回覆
    completed_tools = [] # 已完整的工具調用請求
    current_tool_text = None # 尚未完整的工具調用請求
    start_pos = 0 # 目前進行到的索引(此輪的起點索引)
    full_len = len(full_text) # 目前全文長
    
    while start_pos < full_len: # 遍歷完全文
        start_target_idx = full_text.find(start_tag, start_pos)
        
...

這裡的 start_pos 是為了要在「前一個工具已經完整閉環,接著繼續輸出了第二個工具的情況」時從第一個工具結束後開始搜尋的。


那你可能會想,剛剛搜尋過的內容,再搜一遍不是很浪費效能嗎
這部分,我請 AI 幫我跑了測試,目前專案內的搜尋模式,在 2,452 個字元,內有三個 <tool_call> 的情況下,單次僅需 0.003175 毫秒,換算下來每秒可以處理 7 億個字
所以,從頭搜尋一遍根本不成問題,而且還能大幅精簡代碼
如果要記錄上次搜到哪,還要多一個記錄的參數傳入函數,並且必須寫一大堆複雜的邏輯去記錄:「上次是不是剛好停在 <to 中間?這次新來的字是不是 ol_call>?」。


沒有完整起始標籤時

沒有找到 <tool_call> 時會有兩種情況:「真的沒有工具調用」跟「疑似有工具調用」。

疑似的部分指的是只出現例如 "<tool_ca" 這樣寫到一半的標籤,而真的沒有就是在遍歷完後結尾處連 < 都沒有。

首先,利用 full_text[start_pos:] 切出此次調用多出來的內容trimmed_len 做計數,
我們著重來看一下 range() 的部分,這裡我們要搜尋的目標是如下面的形式:

起點  "<tool_call" -> i = 10 -> len(start_tag) - 1 = 11 - 1
|     "<tool_cal"
|     "<tool_ca"
|	  ...
|	  "<t"
終點	  "<" ---------> i = 1

這就是為什麼起點是 len(start_tag) - 1

這裡的語法為 range(起點, 終點, 步長),而步長可以理解為是:每輪迴圈結束後,要對起點 + 步長
所以如果我們把起點終點對調(起點大終點小),步長為負數,可以達到從後向前遍歷的效果

而在遍歷的過程,我們用 endswith() 檢查結尾是否為目標值。

因為切片終點不包含尾部,剛好 start_tag[:i] 能切出該輪迴圈要尋找的不完整標籤。

接下來就分為有找到和沒找到兩種狀況了:

  1. 如果找到了,我們要記錄下如何切才能將文字和標籤分開,先看個例子:
1. 一開始:
   remaining = "你好<tool"
   trimmed_len = len(remaining) = 7  (總共有 7 個字)

2. 迴圈開始檢查:
   當 i = 5 時,start_tag[:5] 是 "<tool"
   -> remaining.endswith("<tool") 比對成功!(命中碎片,長度 i = 5)

3. 執行 trimmed_len -= i:
   trimmed_len = 7 - 5 = 2   (扣掉這 5 個字,只剩下 2 個字的安全長度!)

4. 最終切片輸出:
   remaining[:trimmed_len] ──> remaining[:2] 
   -> 剛好只取出前 2 個字:"你好"!
   -> 尾巴的 "<tool" (5 個字) 成功被砍掉,完全不會洩漏到畫面上!
  1. 若完全沒有匹配到任何疑似標籤的前綴,則 trimmed_len 維持原長度,切片會將剩餘文字完整保留並加入安全回覆列表。

最後,把這兩種情況切片切出來的值保存到 safe_parts 串列中。

# src/meowgent/agent.py
...
def _extract_safe_text(...) -> ...:
	...
	while start_pos < full_len:
		...
		
		if start_target_idx == -1:
		# 後續沒有完整的 start_tag,檢查剩餘文字尾端是否有正在成形的前綴(如 "<tool")

            remaining = full_text[start_pos:] # 切出新內容
            trimmed_len = len(remaining) # 用於下方迴圈計數
            
            for i in range(len(start_tag) - 1, 0, -1):
                if remaining.endswith(start_tag[:i]):
                    
                    trimmed_len -= i
                    break # 退出 for
                    
            safe_parts.append(remaining[:trimmed_len])
            
            break # 退出 while
...

有找到完整標籤

找到標籤後,我們要做的就是分離回答以及工具了,切出文字內容,放入 safe_parts 串列中:

# src/meowgent/agent.py
...
def _extract_safe_text(...) -> ...:
	...
	while start_pos < full_len:
		...
		
		if start_target_idx == -1:
			...
		
		else:
            # 收集 <tool_call> 之前的安全文字
            safe_parts.append(full_text[start_pos:start_target_idx])

            
...

尋找結束標籤

為了提高搜尋效率,我們將結束標籤 </tool_call> 的搜尋起點設在 <tool_call> 之後(略過起始標籤本身,避免無效比對)**:

<tool_call>
{ -> 此為索引 0
  "name": "read_file",
  "arguments": {"file_path": "src/meowgent/agent.py"}
}
</tool_call>

同樣分成了有搜尋到跟沒有兩種情況:

  1. 還沒找到,放入 current_tool_text
  2. 找到的話,放 completed_tools,並且更新起始索引,繼續 while 迴圈搜尋是否有第二個工具調用

而要注意的是,我們需要的僅是 {...}不需要 <tool_call> 標籤

{
  "name": "read_file",
  "arguments": {"file_path": "src/meowgent/agent.py"}
}

最後,回傳拼接完的字串、未結束和已結束的工具調用。

# src/meowgent/agent.py
...
def _extract_safe_text(...) -> ...:
	...
	while start_pos < full_len:
		...
		
		else:
			...
			
			# 尋找對應的 </tool_call>
            content_start = start_target_idx + len(start_tag)
            end_idx = full_text.find(end_tag, content_start)

            if end_idx == -1:
                # 工具調用尚未閉合
                current_tool_text = full_text[content_start:].strip()
                break
            else:
                completed_tools.append(full_text[content_start:end_idx].strip())
                start_pos = end_idx + len(end_tag) # 更新索引
    
    return "".join(safe_parts), current_tool_text, completed_tools
...

接入分離函數

搞定了這個函數,我們就可以來將其接入 chat() 中了!
把未完成的工具調用和模型回答分別回傳,而完成的工具我們下一篇會對其進行處理。

# src/meowgent/agent.py
...
class Agent():
	...
	
	def chat(...) -> Iterator[LLMResponse]:
		...
		while turns < self.max_turns:
			...
			for chunk in response:
				...
				if chunk.content_chunk:
					...
					result_full_text ...
					
					# 分離
                    safe_text, tool_text, completed_tools = _extract_safe_text(
	                    result_full_text
                    )

                    if tool_text is not None:
                        # 還未完成的工具調用參數
                        yield LLMResponse(status="tool_calling", content=tool_text)
                    elif safe_text:
                        # 模型回答
                        yield LLMResponse(status="response", content=safe_text)

這裡 completed_tools 在下一篇用到。


我們辛辛苦苦的把 completed_tools 給提取出來了,但這可還沒結束呢!
下一篇,要來把這個 JSON 格式(用字串來表示的)的工具調用給解析出來,判斷出是要調用哪個工具函數、要傳入什麼參數、是否需要審核,等等的。


上一篇
Day 5 - 獨立調用管道 - 上
下一篇
Day 7 - 獨立調用管道 - 下
系列文
手刻 AI Agent!大一新生的 Python 實戰筆記7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言