今天這篇和明天這篇,我們開始要對模型得到的 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,進而加快模型的回答速度。
從以下兩個區塊就能直觀的感受到差別了:
{
"name": "write_file",
"description": "寫入到文字檔",
"inputSchema": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "要寫入的目標檔案路徑"
},
"content": {
"type": "string",
"description": "要寫入檔案的完整文字內容"
}
},
"required": [
"file_path",
"content"
]
}
}
JSON 的部分大概看一看就好了,因為我們不會利用它來做處理,而是直接取工具函數用
inspect。
<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)拆成元組。而我們先取出型別 param_type 的部分:
.json_schema() 轉為 JSON 形式。.json_schema() 取出來是 {"type": "..."} 的字典,所以我們要把它給取出。 param_type = TypeAdapter(args_list[0]).json_schema()["type"]
└───────────┬───────────┘ └─────┬────┘ └──┬──┘
步驟 ① 步驟 ② 步驟 ③
(包裝) (轉 JSON) (取值)
再來是後面的註記 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>
.join() 把 params_xml 串列每一元素中間加上換行放入 params_block 字串。inspect.getdoc() 取得工具描述的註解(""" """ 的註解內容)。# 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)!
前面的 _tool_to_mcp_xml() 只針對單個工具做處理,接下來,要把這些組裝起來!
整體還算簡單,我們快速看完:
先來看一下格式長什麼樣:
<tools>
<tool name=...>
...
</tool>
<tool name=...>
...
</tool>
</tools>
這裡,我們只需要在最前和最後加上 <tools>、</tools> 即可。
建立字串 _MCP_TOOL_CACHE 做快取,同樣為內部私有,運行時只做一次轉換,後續直接取用。
get_all_xml()取用快取時,由於快取建立在外部,用global取得。
判斷出還未轉換過時,從工具註冊表 TOOL_REGISTRY 取出函數自身,傳入 _tool_to_mcp_xml() 獲取 XML 格式,暫時放到 tool_nodes 裡。
若已經有
_MCP_TOOL_CACHE就不做動作。
.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 與規則打包餵給模型!