什麼是 Model Context Protocol (MCP)?PostgreSQL 打造即時 AI 應用的關鍵技術

模型上下文協定(Model Context Protocol, MCP)是由 Anthropic 釋出的開放標準,它就像 AI 界的 USB-C,提供了一個統一的協定,讓大型語言模型(LLM)能夠安全、即時地連接外部資料來源與開發工具

如果要求 LLM 針對正式環境資料庫寫 SQL,寫出來的結構通常看起來正確,但可能引用了不存在的資料表、半年前就改名的欄位,或憑空捏造 JOIN 條件。這會導致查詢直接失敗,甚至面臨更糟情況:指令成功執行,卻回傳了錯誤的數據。

為了解決 LLM 亂寫 SQL 的問題,目前的作法是透過 MCP,利用 PostgreSQL 的原生架構來確保查詢的準確性。

技術背景與初步嘗試

在 2024 年初還沒有 MCP 或 AI Agent 的概念。為了讓聊天機器人能串接 API 取得即時資料,當時通常採用比較拼湊的流程:

  1. 把 OpenAPI spec 餵給 LLM。
  2. 要求 LLM 只能回傳 curl 指令。
  3. 透過 Go 函式去執行這個 curl 指令。
  4. 把執行的結果再丟回給 LLM,讓它轉換成一般人看得懂的回答。
User: "What's the CPU usage and total DB connections right now?"
 -> LLM + API Spec -> curl -X GET https://portal.curiousone.in/api/v1/metrics?names=cpu,db_conn
 -> execute() -> { cpu: 72%, conn: 84 }
 -> LLM: "CPU is at 72% and there are 84 active DB connections."

這做法雖然可行,但完全無法擴充。每增加一個新資料來源,就得寫一套新的 parser;而且 Prompt 工程脆弱,必須針對每一種 spec 或 schema 重新調整。

後來也發現不少團隊在開發這類應用時,也都面臨一模一樣的挑戰。直到 Anthropic 釋出 MCP作為開放標準,問題才得到解決。

為何 LLM 總是寫錯 SQL?

LLM 其實看得懂 SQL 語法,也能寫出包含 CTE、子查詢的複雜指令。但它無法得知資料表名稱、欄位名稱或關聯性,只能單純根據訓練資料的盲猜。

對比如下:

LLM 生成的內容正式環境實際存在的內容
SELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.user_idSELECT c.full_name, o.total_price FROM app_customers c JOIN customer_orders o ON c.id = o.customer_id
結果: ERROR: relation “users” does not exist結果: SUCCESS: 47 rows returned

LLM 猜測資料表叫 users,但實際上是 app_customers;猜測欄位叫 name,但實際上是 full_name;猜測外鍵(Foreign Key)是 o.user_id,但實際上是 o.customer_id。它的每一次猜測聽起來都很合理,但結果全都是錯的。

上下文斷層(Context Gap)

這類錯誤歸根究底,都是因為缺乏上下文所導致的:

錯誤類型範例缺少的上下文
資料表錯誤FROM users實際的資料表是 app_customers
欄位錯誤SELECT email FROM orders該資料表根本沒有這個欄位
JOIN 錯誤ON users.id = orders.id應該要是 orders.user_id
型態錯誤WHERE created_at = '2024'該欄位是 timestamptz,而不是字串(String)
資料庫語系錯誤DATEADD(day, 7, now())這是 SQL Server 的語法,不是 Postgres

解決這個問題的關鍵並不是去寫更好的 Prompt,而是要在 LLM 進行查詢的當下,直接讓它讀取到真實的資料庫 Schema。

解決方案:Model Context Protocol (MCP)

MCP 是一種開放標準,主要用來連接 AI 模型與外部資料來源和工具。

在技術架構上的核心特點是:單一 MCP 用戶端可以連接多個 MCP 伺服器,而每個伺服器各自封裝不同的資料來源。以 Postgres 運作來說,代表同一個 AI 助理可以同時存取多個資料來源,例如:

                    ┌─ Postgres MCP Server ──→ PostgreSQL (live schema & queries)
                    │
MCP Client ─────────┼─ Filesystem MCP Server ─→ PG Logs (log file analysis)
(Claude, IDEs)      │
                    └─ Prometheus MCP Server ─→ Telemetry (metrics & alerting data)

在這個架構中,Client 端就是 AI 應用程式,而每個 Server 端則提供工具供 LLM 呼叫,彼此透過 stdio 或 SSE 傳輸協定來跑 JSON-RPC 2.0。LLM 會根據使用者的問題,自己決定要呼叫哪一個 Server。

舉例來說:如果詢問的是 Schema 相關問題,它會去找 Postgres MCP;但如果問的是「為什麼昨天晚上資料庫變慢?」,就可能同時呼叫 Filesystem MCP(去看 Log 紀錄)和 Prometheus MCP(去抓指標數據)。

核心組成要素

在 MCP 的三種基本元件中,資料庫應用最核心的是 Tools:

  • Tools: 這是供 LLM 呼叫的函式,會直接回傳即時結果。例如 execute_sqllist_schemaslist_objects 等,這類工具回傳的是最新狀態,而不是過期的快照。
  • Resources: 透過 URI 提供的唯讀靜態資料。如果把資料庫 Schema 當成這種靜態資源發布,資訊很快就會過期,因此並不適合用來處理動態的資料庫元資料。
  • Prompts: 封裝了上下文與指令的重複使用範本。雖然實用,但在資料庫的操作中屬於輔助角色。

PostgreSQL 結合 MCP:pg-airman-mcp

pg-airman-mcp 是一款針對 PostgreSQL 設計的 MCP 伺服器,能讓 LLM 即時存取資料庫的結構與資料。以下是它所提供的核心工具:

工具名稱功能說明
list_schemas列出所有資料庫 Schema,並自動區分為系統或使用者,是讓 LLM 探索資料庫結構的起點。
list_objects列出特定 Schema 內的資料表、檢視表、序列、函式以及相關註解。
get_object_details獲取特定物件的詳細資訊,包含欄位、資料型態、條件約束、索引以及註解。
explain_query執行 EXPLAIN 執行計畫,並能在不實際建立索引的情況下,透過 HypoPG 模擬虛擬索引的效果。
execute_sql執行 SQL 查詢,具備可自訂的存取控制、唯讀模式與安全的 SQL 解析機制。
analyze_query_indexes評估上千種可能的索引組合,找出最符合當前工作負載的最佳化配置。

查詢流程

當使用者詢問「幫我列出營收前五名的客戶」時,LLM 不再用盲猜的方式,而是依序執行以下步驟:

  1. 呼叫 list_schemas: 確認目前有哪些可用的 Schema。
  2. 呼叫 list_objects: 取得真實的資料表與檢視表(View)名稱。
  3. 呼叫 get_object_details: 掌握欄位、資料型態、條件約束與索引。
  4. 呼叫 explain_query: 驗證執行計畫並檢查效能。
  5. 呼叫 execute_sql: 在具備存取控制與安全解析的機制下執行查詢。

到了第 3 步,LLM 已經明確知道資料表叫 app_customers、欄位是 full_name,且外鍵是 customer_id。整個過程完全不需要任何猜測。

透過 pg_catalog 讓 Postgres 自行維護元資料

這套架構最核心的優勢,在於 Postgres 本身就已經完整記錄了所有需要的元資料。MCP 伺服器完全不需要依賴外部的設定檔、Schema 傾印檔案,或是常常忘記更新的系統文件。它只需要直接查詢 Postgres 的系統目錄:

  • pg_class:記錄所有的資料表、檢視表與序列。
  • pg_attribute:記錄欄位名稱與資料型態。
  • pg_constraint:記錄主鍵(PK)、外鍵(FK)與 CHECK 條件約束。
  • pg_index:記錄用於查詢最佳化的索引資訊。
  • pg_namespace:記錄 Schema。
  • pg_description:記錄 COMMENT ON 的註解內容(這能直接當作 LLM 的文件說明)。

在底層運作上,MCP 伺服器就是透過執行這類的查詢來獲取即時資訊:

-- list_schemas
SELECT
  schema_name, schema_owner,
  CASE
    WHEN schema_name LIKE 'pg_%' THEN 'System Schema'
    WHEN schema_name = 'information_schema' THEN 'System Info Schema'
    ELSE 'User Schema'
  END AS schema_type
FROM information_schema.schemata
ORDER BY schema_type, schema_name;
-- list_objects
SELECT
  CASE c.relkind
    WHEN 'r' THEN 'table'
    WHEN 'v' THEN 'view'
  END AS object_type,
  n.nspname AS table_schema,
  c.relname AS table_name,
  d.description AS comment
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN pg_catalog.pg_description d
  ON d.objoid = c.oid AND d.objsubid = 0
WHERE n.nspname = $1 AND c.relkind IN ('r','v')
ORDER BY c.relname;
-- get_object_details
SELECT
  column_name, data_type,
  is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = $1
  AND table_name = $2
ORDER BY ordinal_position;

這些查詢是直接針對線上資料庫即時執行。不論欄位是新增、改名或刪除,MCP 伺服器都能在第一時間同步反應最新狀態。

透過 EXPLAIN 最佳化查詢

掌握正確的資料表與欄位是第一步,接下來還必須確保寫出來的查詢具備良好的執行效率。

透過 explain_query 工具,系統會把 LLM 生成的 SQL 先丟給 Postgres 的查詢規劃器(Query Planner)進行評估。如果 EXPLAIN 的結果顯示該執行計畫不夠理想,LLM 就能在幾毫秒內、在使用者完全沒有察覺的情況下,重寫並修正語法,才正式執行。

例如,以下是 LLM 的第一次嘗試:

SELECT c.full_name, SUM(o.total_price)
FROM   app_customers c
JOIN   customer_orders o ON c.id = o.customer_id
GROUP BY c.full_name
ORDER BY total DESC;

透過 EXPLAIN 的分析發現:這段查詢會對約 50 萬筆資料進行全表掃描,且沒有設定日期篩選(導致掃描了歷史至今的所有訂單),執行時間長達近 3,000 毫秒。

LLM 讀取 EXPLAIN 的輸出結果後,便會主動重寫 SQL:

SELECT c.full_name, SUM(o.total_price)
FROM   app_customers c
JOIN   customer_orders o ON c.id = o.customer_id
WHERE  o.created_at >= NOW() - INTERVAL '90 days'
GROUP BY c.id, c.full_name
ORDER BY total DESC
LIMIT 10;

優化後轉為索引掃描,掃描筆數縮減至約 3,000 筆,執行時間縮短到 20 毫秒,速度提升了約 150 倍。

在這個過程中,LLM 透過分析 EXPLAIN 執行計畫的結果,主動加上了日期篩選條件、修正了 GROUP BY(納入 c.id 以確保資料正確性),並加上了 LIMIT。這完全是基於即時的執行計畫評估,而不是死背硬記的最佳化規則。

安全防護機制(Guardrails)

要讓 LLM 能夠存取資料庫,必須部署多層安全機制。以下是推薦的標準配置方法:

首先,從配置唯讀帳號開始,並且只授予 GRANT SELECT 權限。這樣一來,即使 LLM 生成了 DROP TABLE 這類的指令,Postgres 也會在資料庫引擎層級直接拒絕執行。

CREATE ROLE mcp_reader LOGIN PASSWORD '...';
GRANT CONNECT ON DATABASE mydb TO mcp_reader;
GRANT USAGE ON SCHEMA public TO mcp_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO mcp_reader;

針對多租戶架構的資料庫,可以啟用資料列級安全性(Row-Level Security, RLS)。這能直接在 Postgres 引擎層級強制隔離資料,即使面臨 SQL 注入(SQL Injection)攻擊也無法繞過這層防禦。

ALTER TABLE customer_orders ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON customer_orders
  FOR SELECT USING (
    tenant_id = current_setting('app.tenant_id')::int
  );

execute_sql 工具本身也提供了可自訂的存取控制、強制唯讀模式、安全的 SQL 解析機制,以及針對長時間異常查詢的 statement_timeout 限制。系統也會為每筆查詢記錄包含上下文的 Log,並強制執行速率限制。

這些安全機制並非選配,而是將 MCP 應用在任何真實資料庫時都必須具備的基本防線。

開始使用 pg-airman-mcp

只花幾分鐘,就能完成 pg-airman-mcp 的設定。請將以下內容新增到 MCP 用戶端設定檔中:

{
  "mcpServers": {
    "postgres": {
      "command": "docker",
      "args": [
        "run",
        "-i",
        "--rm",
        "-e",
        "AIRMAN_MCP_DATABASE_URL",
        "enterprisedb/pg-airman-mcp",
        "--access-mode=unrestricted"
      ],
      "env": {
        "AIRMAN_MCP_DATABASE_URL": "postgresql://mcp_reader:pass@localhost:5432/mydb"
      }
    }
  }
}

在正式環境中,請使用 --access-mode=restricted(受限模式)。完成後重新啟動你的 MCP 用戶端,接下來就能自由進行任何查詢了。(更多設定説明

更棘手的問題:悄悄出錯的查詢

Schema 探索機制只能解決表面上的明顯錯誤,例如寫錯資料表、漏掉欄位或語法錯誤的 JOIN。但還有另一個問題是它無法解決的。

當你詢問 LLM:「我們這個月的營收是多少?」時,SQL 順利執行了,跑出來的數據看起來也非常合理。但實際上,那個數字是錯的。

-- What the LLM generates
SELECT SUM(o.total_price)
FROM   customer_orders o
WHERE  o.created_at >= '2026-04-01'

這段查詢能正常執行,也會回傳一個數字。但這個數字卻把「已取消的訂單」、「已退款的訂單」,以及 is_deleted = true 的軟刪除(Soft-deleted)資料全都算進去了。

正確的查詢應該是:

-- What the query should be
SELECT SUM(o.total_price)
FROM   customer_orders o
WHERE  o.created_at >= '2026-04-01'
  AND  o.status != 'cancelled'
  AND  o.is_deleted = false
  AND  o.is_refunded = false

這就是所謂的「內部潛規則」。這些邏輯只存在於開發人員的腦袋裡,根本沒有寫進 Schema。不論再怎麼去查 pg_catalog,系統目錄也絕對不會告訴你:只要查詢 customer_orders,就必須一律加上 is_deleted = false 這個篩選條件。

解決方案:利用 FastMCP 客製化 MCP 伺服器

解決這個問題的方法,是將業務邏輯直接封裝進 MCP 工具中。讓 LLM 直接呼叫一個早已寫好規則的工具,而不是讓它從頭開始盲寫 SQL。

from fastmcp import FastMCP
import psycopg2

mcp = FastMCP("revenue-server")

@mcp.tool()
def get_monthly_revenue(month: str, year: int) -> dict:
    """Get actual revenue excluding cancelled,
    refunded, and soft-deleted orders."""
    conn = psycopg2.connect(DB_URL)
    cur = conn.cursor()
    cur.execute("""
        SELECT SUM(o.total_price)
        FROM   customer_orders o
        WHERE  DATE_TRUNC('month', o.created_at)
               = DATE_TRUNC('month', %s::date)
        AND    o.status != 'cancelled'
        AND    o.is_deleted = false
        AND    o.is_refunded = false
    """, [f"{year}-{month}-01"])
    result = cur.fetchone()
    return {"revenue": float(result[0] or 0)}

if __name__ == "__main__":
    mcp.run()

透過 Python 的 Docstring(文件字串),我們可以直接告訴 LLM 這個工具的功能;而真實的業務規則則已經寫死在函式實作中。這樣一來,當 LLM 遇到營收相關的問題時,它會直接呼叫 get_monthly_revenue,而不是自己瞎猜語法。

混合模式

在實際應用中,這兩種做法缺一不可,必須搭配使用。

  • Postgres MCP 負責結構層: 處理 Schema、資料表、欄位與外鍵,告訴 LLM 資料庫裡「存在哪些東西」,防止 LLM 產生結構上的幻覺(Structural Hallucinations)。
  • 客製化 MCP 負責邏輯層: 封裝了業務邏輯、篩選條件、合規規則與團隊內部的潛規則,告訴 LLM 這些資料「代表什麼意思」,杜絕語義上的幻覺(Semantic Hallucinations)。

實際運作時的互補效果:

  • 面對欄位命名古怪的舊系統?由 Postgres MCP 即時探索結構。
  • 面對複雜的狀態旗標或潛規則 JOIN?由客製化 MCP 內建規則。

為什麼不直接用其他方法?

要把資料庫的 Context 提供給 LLM,其實還有其他幾種做法。以下是它們與 MCP 的對比:

作法 資料即時性 擴充性最終評語
直接把 Schema 貼進提示詞瞬間過期浪費 Token僅適合微型專案或快速測試
對文件進行 RAG向量資料易與真實 Schema 脫節良好適合處理語義,不適合精準的結構
微調模型 (Fine-tuning)部署的瞬間就開始過期成本高昂用來記 Schema 太大材小用
MCP (即時連線)永遠保持最新狀態模組化、易組合處理結構資訊的最佳解
  • 直接貼 Schema: 這種做法玩玩小專案還可以,一旦資料表結構發生變化就直接破功。
  • RAG 模式: 適合用來對文件進行語義搜尋,但面對需要百分之百精準的「結構化元資料」時,表現並不理想。
  • 微調模型 等於是把 Schema 的硬編碼進模型裡,只要資料庫一版更,模型懂的知識就過期了。
  • MCP 機制: 每次遇到提問,都是直接去查當下最即時的線上資料庫。

核心結論

  • 精準的 Prompt 無法解決幻覺: LLM 會寫出錯誤的 SQL,核心原因在於它們缺乏資料庫的即時上下文,這一點光靠優化提示詞是無法根治的。
  • MCP 是橋接的關鍵: MCP 是一個將 LLM 連接到外部資料的開放協定。pg-airman-mcp 正是利用了 pg_cataloginformation_schema,將 Postgres 原生的「自我認知」直接轉化為 LLM 的背景知識。
  • 客製化 MCP 補足盲點: 透過 FastMCP 建立的客製化 MCP 伺服器,能進一步將業務邏輯與團隊內部的「潛規則」封裝起來,解決單靠結構探索無法得知的業務語義。
  • 雙劍合璧才是正解: 運用 Postgres MCP 搞定結構,加上 客製化 MCP 搞定語義 —— 這種混合模式才是讓 LLM 真正落地、搞定企業級複雜資料庫的終極方案。

本文翻譯自:Building Real-Time, Data-Aware Intelligence with Postgres and the Model Context Protocol

參考資料:

想瞭解更多?歡迎聯絡我們,或是 加入歐立威 Line 好友!

Related Posts