iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0

今天,終於是工具部分的最後一篇了,讓我們繼續完成最後兩個工具吧!


終端工具

很多的操作都可以用終端的指令來完成,賦予 Agent 執行 Shell 指令的能力,能大幅拓展其自主解決問題的範圍,我們來實作看看吧!

這裡參數只有一個,也就是要傳入終端的指令:

# src/meowgent/tool.py
import subprocess
...

@tool_register(True) # 需要審核
def run_shell(
    command: Annotated[str, "要在系統 Shell 執行的指令"]
) -> str:
    """ 執行終端指令 """

這裡直接用 subprocess.run() 運行指令:

# src/meowgent/tool.py
...
@tool_register(...)
def run_shell(...) -> str:
	""" ... """
	
    try:
        execution = subprocess.run(
            command,
            shell=True, 
            capture_output=True, # 回傳輸出或錯誤
            text=True, # 轉字串
            timeout=30, # 超過 30 秒則退出
            errors="replace" # 防止解碼報錯
        )

shell=True 我們仔細來說一下:
首先,解釋一下終端跟 Shell 的差別
前者只是一個使用者介面,它只負責輸入及輸出,而 Shell 才是那個讀懂指令的「大腦」,也就是說在終端機視窗裡打字,但真正負責看懂指令並執行的是 Shell

shell 原本預設是 False,改成 True 後簡單來說就是「讓 Python 透過系統 Shell(如 bash、zsh 或 cmd)去解析整行指令,能使用管道 | 與萬用字元 *,但若拼接外部字串會帶來命令注入的資安風險(惡意代碼夾帶在字串裡)」。
這裡有個問題,「透過 Shell 解析」是什麼意思,不然要透過什麼?

我們先了解一下「指令」到底是什麼?本質上它就是存在你電腦裡的「執行檔」,而這個執行檔,由系統核心(Kernel)直接執行,而「Shell」只是一個負責看懂你輸入的東西(也就是解讀語法,例如,在兩條指令中間加上 |(管道)可以把前者執行的輸出給後者做輸入),並轉成給核心的指令而已。
在這裡,設定的 shell=True 代表指令會交由 Shell 解析完整語法,因此可以直接傳入包含複雜語法的字串指令。

那上面感覺都在說 shell=True 不好,為什麼這裡還這麼做呢?
最主要就是因為模型常會用「包含語法」的指令做事,而這裡的資安風險,將透過我們後續加入的「人工審核機制」進行防範與把關。

接著,來解析指令回傳結果:
如果 .returncode 是 0 代表有成功執行,查看是否有回傳東西(沒回傳東西自己補上(指令執行完成,無輸出內容));反之則回報失敗狀況。

# src/meowgent/tool.py
...
@tool_register(...)
def run_shell(...) -> str:
	""" ... """
	
    try:
	    ...
    
		if execution.returncode == 0: # 成功執行
            return execution.stdout if execution.stdout != "" else "
	            (指令執行完成,無輸出內容)
            "
		else:
            return f"指令執行失敗(結束代碼 {execution.returncode}):\n
	            標準輸出 (Stdout):{execution.stdout}\n
	            標準錯誤 (Stderr):{execution.stderr}"

最後,加入指令逾時以及其他的例外捕捉:

# src/meowgent/tool.py
...
@tool_register(...)
def run_shell(...) -> str:
	""" ... """
	
    try:
	    ...
	    
    except subprocess.TimeoutExpired: # 攔截超時
        return f"錯誤:指令 '{command}' 執行超時(超過 30 秒)。"
    except Exception as e:
        return f"錯誤:執行指令時發生錯誤:{e}"

讓 Agent 可以上網

模型的知識僅止於訓練資料獲取的當下,在資訊瞬息萬變的時代,訓練當時的資料可能都已經過時了,這時就需要「網頁抓取」功能!
而為什麼是說網頁抓取呢?這是因為這個工具本質上其實就只是一個「純文字的 HTTP 下載器」,而不是一個完整的瀏覽器,那麼,讓我們開始最後一個工具的製作吧!

這裡參數部分除了網址,同樣也需要 offsetlimit 對上下文做限制:

# src/meowgent/tool.py
...
import httpx
from readability import Document
import html2text

...

@tool_register(True) # 需要審核
def web_fetch(
    url: Annotated[str, "要抓取內容的網頁網址(必須以 http:// 或 https:// 開頭)"],
    offset: Annotated[int, "讀取內容的起始字元偏移量(預設為 0,用於分頁讀取長網頁)"] = 0,
    limit: Annotated[int, "本次讀取的最大字元數量(預設為 3000)"] = 3000
) -> str:
    """ 獲取網頁內容並轉成文字 """

1. 驗證為網址與發送 HTTP 請求

先驗證確認是網址:

# src/meowgent/tool.py
...

@tool_register(...)
def web_fetch(...) -> str:
	""" ... """
	
	if not url.startswith(("http://", "https://")): # 非網址
		return "錯誤:這不是一個有效的網址(URL)"

爬蟲時,需要做一個標頭字典 headers 來繞過反爬蟲,接著就可以 httpx.get() 來獲取內文了,
內文在 content.decode() 裡,將其取出放入 text(採用請求獲取的編碼或 "utf-8"errors="ignore" 防止編碼造成報錯),
下面還需要加上 httpx.get() 的例外捕捉。

response.raise_for_status() 為了捕捉連線異常 httpx.HTTPStatusError

# src/meowgent/tool.py
...

@tool_register(...)
def web_fetch(...) -> str:
	...
	
	headers = {
		"User-Agent": "
			Mozilla/5.0 (Windows NT 10.0; Win64; x64) 
			AppleWebKit/537.36 (KHTML, like Gecko) 
			Chrome/120.0.0.0 Safari/537.36
		"
	} # 偽裝為用戶,繞過反爬蟲
	
	try:
			response = httpx.get(
				url = url, # 網址
				headers=headers, # 請求的標頭字典(上方設定的)
				follow_redirects=True, # 301 / 302 重定向轉址(防網址搬家)
				timeout=10 # 逾時限制
			)
		response.raise_for_status() # 拋出連線異常 httpx.HTTPStatusError

		text = response.content.decode(
			response.encoding or "utf-8",
			errors="ignore"
		)

	except httpx.HTTPStatusError as e: # 攔截伺服器錯誤
		return f"
			錯誤:HTTP 狀態碼 {e.response.status_code} ({e.response.reason_phrase})
		"

	except httpx.RequestError as e: # 攔截網路連線錯誤
		return f"錯誤:網路連線失敗:{e}"


2. 內容提純及 HTML -> Markdown

網頁提取下來後,會有很多無關緊要的內容,例如頂部導覽頁、頁尾、甚至是廣告,我們將其做刪減,縮減上下文壓力:
這裡利用 Document(text).summary() 做到,並且後面加上「確保內容沒有被過度刪減」的邏輯。

# src/meowgent/tool.py
...

@tool_register(...)
def web_fetch(...) -> str:
	...
	
	html_summary = Document(text).summary()

	if html_summary and len(html_summary) > 100: # 存在且沒有過度刪減
		text = html_summary

網頁用的 HTML 有很多不必要的標籤,如果直接給模型,同樣會浪費 token,我們要將其轉為 Markdown 來做優化:
首先,先 html2text.HTML2Text() 接著對其做設定,最後 .handle() 做轉換。

# src/meowgent/tool.py
...

@tool_register(...)
def web_fetch(...) -> str:
	...
	
    h = html2text.HTML2Text()
    
    h.ignore_images = True # 忽略圖片
    h.body_width = 0 # 不做自動換行
    h.single_line_break = True # 換行不隔行 -> 避免 .md 格式的兩行之間隔一行

    text = h.handle(text)

3. 內容切片

還是老話一句,節省上下文,提取出來的內容我們還得做切片,先來定位切片位置:

  1. 判斷 offset 不能比提取出來的內容長(起點不能比全文長)。
  2. 再來,判斷 raw_end(結尾)比全文長則結尾為全文尾。
  3. else 裡,是為了不要讓內容斷在一半:
    方法是設定的結尾處往回推 500 字,從最後面開始搜尋(.rfind())換行("\n\n")作為自然的結束點,
    如果 target_idx 的結果(也就是 .rfind())的回傳為 -1,則代表沒有搜尋到斷點,只好用原先的 raw_end
# src/meowgent/tool.py
...

@tool_register(...)
def web_fetch(...) -> str:
	...
	
	total_len = len(text)

    if offset > total_len: # 起始點大於總文長
        return f"錯誤:指定的 offset ({offset}) 超出網頁內容總長度 ({total_len})"

    raw_end = offset + limit

    if raw_end >= total_len:
        end = total_len
    
    else: # 讓內容不斷在一半
        search_start = max(offset, raw_end - 500) # 往回推 500 為搜尋邊界

        target_idx = text.rfind("\n\n", search_start, raw_end)
        # 找最後出現的,故從右開始找

        end = target_idx if target_idx != -1 else raw_end

接著把回傳搞定:

# src/meowgent/tool.py
...

@tool_register(...)
def web_fetch(...) -> str:
	...
	
	suffix = f"
		\n\n[內容已截斷。若要閱讀下一頁,請呼叫 web_fetch 並帶入 offset={end}]
	" if total_len > end else ""

    return f"
	    [顯示字元區間 {offset}~{end},總字數為 {total_len}]\n
    " + text[offset:end] + suffix

Day 2 到今天 Day 4 這篇,我們完成了最簡單的讀寫、讓 Agent 可以看到檔案、檔案內部搜尋、終端、網路,六個工具函數了,這麼一來,基礎的工具我們都已經完成了!
而其實,還有非常多的工具可以做實作,像是 PDF 檔的讀取工具等等的,有興趣的可以自己試著做做看~
接著下一篇,來嘗試把工具給接入模型吧!


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

尚未有邦友留言

立即登入留言