iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0

今天這篇和明天這篇,我們開始要對模型得到的 prompt 做一些基礎的處理了,這裡先看一下檔案結構的部分:

src/meowgent/
 ├── ...
 └── prompt/
      ├── __init__.py
      ├── mcp_schema.py            # 1. 轉換內部工具 2. 外部插件做格式驗證
      └── system_prompt.py         # 1. 把 MCP 格式做打包合併 2. 將所有提示詞整合餵給模型
# `src/meowgent/prompt/__init__.py
from .mcp_schema import get_all_xml
from .system_prompt import get_system_prompt

這一篇的目標是要把工具調用的資訊「轉換為讓模型更容易吸收的 XML 格式」!先了解一下為什麼要轉換:
先前我們註冊到 MCP server 的工具是以 JSON Schema 的形式儲存,但是這樣的形式充斥大量重複的結構關鍵字(如 "type": "object""properties""required": [...]),工具不多的話可能還好,一旦工具數量多起來了,上下文就會被不必要的結構給佔用。
XML 標籤非常緊湊可以省下不少的 token,進而加快模型的回答速度。
從以下兩個區塊就能直觀的感受到差別了:

  1. JSON:
    {
      "name": "write_file",
      "description": "寫入到文字檔",
      "inputSchema": {
        "type": "object",
        "properties": {
          "file_path": {
            "type": "string",
            "description": "要寫入的目標檔案路徑"
          },
          "content": {
            "type": "string",
            "description": "要寫入檔案的完整文字內容"
          }
        },
        "required": [
          "file_path",
          "content"
        ]
      }
    }
    

JSON 的部分大概看一看就好了,因為我們不會利用它來做處理,而是直接取工具函數用 inspect

  1. XML:
    <tools>
      <tool name="write_file">
        <description>寫入到文字檔</description>
        <parameters>
          <parameter name="file_path" type="string" required="true">
            <description>要寫入的目標檔案路徑 (必填)</description>
          </parameter>
          <parameter name="content" type="string" required="true">
            <description>要寫入檔案的完整文字內容 (必填)</description>
          </parameter>
        </parameters>
      </tool>
    </tools>
    

一個一個轉換

這個函數負責的是將輸入的工具函數(一次一個)轉換為 XML 形式輸出(用的是字串)。
先來做初始化的部分:

  • 此函數我們只供本檔案等會要實作的 get_all_xml() 使用,所以我們用底線標上「內部私有」。
  • inspect.signature() 提取出這個工具函數的「簽名」,後續用這個「簽名」來提取參數、註釋、型別
  • params_xml 串列作為每一行的儲存,後續再用 join() 拼接為字串。
# src/meowgent/prompt/mcp_schema.py
import inspect
import textwrap
from typing import Callable, get_args
from pydantic import TypeAdapter

def _tool_to_mcp_xml(tool: Callable) -> str:
    """將單個 Python 工具函式轉換為緊湊的 XML 節點格式"""
    sig = inspect.signature(tool)
    params_xml = []

提取參數的型別與說明

sig.parameters 其實就是一個字典,用 for 取出參數名和 Parameter 物件:

  • get_args(arg.annotation)arg.annotation 是為了取得 Annotated[] 這整個標註,然後用 get_args()Annotated[] 裡的型別(索引 0)、文字描述(索引 1)拆成元組。
  1. 而我們先取出型別 param_type 的部分:

    1. 這裡要注意的是我們要給的型別要是 JSON 的型別,所以我們要先將 Python 的型別透過 TypeAdapter 包裝,可以理解為先翻譯成一種通用的標準說法。
    2. 轉成這種通用標準後,才能用 .json_schema() 轉為 JSON 形式。
    3. 而因為 .json_schema() 取出來是 {"type": "..."} 的字典,所以我們要把它給取出。
      param_type = TypeAdapter(args_list[0]).json_schema()["type"]
    		       └───────────┬───────────┘  └─────┬────┘ └──┬──┘
    					    步驟 ①                步驟 ②    步驟 ③
    					    (包裝)               (轉 JSON)   (取值)
    
  2. 再來是後面的註記 desc 就很簡單了,只需取出索引 1 即可。

    # src/meowgent/prompt/mcp_schema.py
    ...
    
    def _tool_to_mcp_xml(...) -> str:
    	...
    
    	for arg_name, arg in sig.parameters.items():
    
            args_list = get_args(arg.annotation)
            param_type = TypeAdapter(args_list[0]).json_schema()["type"]
            desc = args_list[1]
    

必填/選填判定

這裡我們要做是否有預設值的判斷,也就是「是否一定要填」:
同樣是利用到 Parameter 物件做判斷(如果 .default 等於 inspect.Parameter.empty 代表無預設值)

# src/meowgent/prompt/mcp_schema.py
...

def _tool_to_mcp_xml(...) -> str:
	...
	
	for arg_name, arg in sig.parameters.items():
		...
		
		is_required = (arg.default == inspect.Parameter.empty)
        required_str = " (必填)" if is_required else " (選填)"
        required_attr = "true" if is_required else "false"

完成參數部分

我們再來著重看一下 XML 的格式中的參數部分,也就是我們目前要關注的部分:

      <parameter name="file_path" type="string" required="true">
        <description>要寫入的目標檔案路徑 (必填)</description>
      </parameter>
      <parameter name="content" type="string" required="true">
        <description>要寫入檔案的完整文字內容 (必填)</description>
      </parameter>
12345678 -> 縮排部分

而這些數據剛才都已經獲取到了,把它給填上吧!:
這裡我們先不管為什麼會有 12 甚至 14 格這麼多的縮排,等等再解釋。

# src/meowgent/prompt/mcp_schema.py
...

def _tool_to_mcp_xml(...) -> str:
	...
	
	for arg_name, arg in sig.parameters.items():
		...
		
		params_xml.append(f'            <parameter name="{arg_name}" type="{param_type}" required="{required_attr}">')
        params_xml.append(f'              <description>{desc}{required_str}</description>')
        params_xml.append('            </parameter>')
        #                   12345678901234 -> 縮排部分

這裡又用了單引號,還記得之前也有說明過這部分嗎?
沒錯,因為如果字串還是堅持用雙引號,內部的雙引號就要做跳脫處理。


整體的組裝

參數部分搞定了,接下來關注到其餘部分的格式:

<tools>
  <tool name="write_file">
    <description>寫入到文字檔</description>
    <parameters>
      ...
    </parameters>
  </tool>
</tools>
  1. 先用 .join()params_xml 串列每一元素中間加上換行放入 params_block 字串。
  2. inspect.getdoc() 取得工具描述的註解(""" """ 的註解內容)。
  3. 最後把它們組合起來回傳就搞定了。
# src/meowgent/prompt/mcp_schema.py
...

def _tool_to_mcp_xml(...) -> str:
	...
	
	for arg_name, arg in sig.parameters.items():
		...
		
	params_block = "\n".join(params_xml)
    doc = inspect.getdoc(tool) or ""
    
    return textwrap.dedent(f"""
        <tool name="{tool.__name__}">
          <description>{doc.strip()}</description>
          <parameters>
            {params_block.lstrip()}
          </parameters>
        </tool>
    """).strip()

textwrap.dedent()移除整個多行字串中所有非空白行所共同擁有的最小前導空白

那這裡來看一下為什麼需要 12 格縮排
因為外層模板中的 <tool> 本身已有 8 格縮排(此即整個區塊的最小前導空白)。當 textwrap.dedent() 統一切除這 8 格後,params_xml 的 12 格縮排減去 8 格,正好剩下標準的 4 格縮排(12 − 8 = 4)!


組裝起每個 XML

前面的 _tool_to_mcp_xml() 只針對單個工具做處理,接下來,要把這些組裝起來!
整體還算簡單,我們快速看完:
先來看一下格式長什麼樣:

<tools>
  <tool name=...>
    ...
  </tool>
  <tool name=...>
    ...
  </tool>
</tools>

這裡,我們只需要在最前和最後加上 <tools></tools> 即可。

  1. 建立字串 _MCP_TOOL_CACHE 做快取,同樣為內部私有,運行時只做一次轉換,後續直接取用。

    get_all_xml() 取用快取時,由於快取建立在外部,用 global 取得。

  2. 判斷出還未轉換過時,從工具註冊表 TOOL_REGISTRY 取出函數自身,傳入 _tool_to_mcp_xml() 獲取 XML 格式,暫時放到 tool_nodes 裡。

    若已經有 _MCP_TOOL_CACHE 就不做動作。

  3. .join() 加上換行,並完善 <tools></tools>,最後就能回傳啦!

# src/meowgent/prompt/mcp_schema.py
from ...
from tool import TOOL_REGISTRY

_MCP_TOOL_CACHE = ""

def _tool_to_mcp_xml(...) -> str:
	...

def get_all_xml() -> str:
    """取得所有已註冊工具的完整 XML 區塊(帶快取)"""
    global _MCP_TOOL_CACHE
    
    if not _MCP_TOOL_CACHE:
        tool_nodes = []
        for _, tool in TOOL_REGISTRY.items():
            tool_nodes.append(_tool_to_mcp_xml(tool))
        
        # 組裝成完整的 <tools> XML 區塊
        _MCP_TOOL_CACHE = "<tools>\n" + "\n".join(tool_nodes) + "\n</tools>"
        
    return _MCP_TOOL_CACHE

工具格式的部分已經轉換完成,上下文得到了不少的緩解。

接下來下一篇,我們將實作系統提示詞整合函數,將這份工具 XML 與規則打包餵給模型!


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

尚未有邦友留言

立即登入留言