LangChain.js callback handler
LangChain.js(
@langchain/core@^0.3)是目前 TS agent 圈最主流的一套。SpendGuard 以SpendGuardCallbackHandler形式出貨 —— 就是一個標準的BaseCallbackHandler, 透過callbacks: [handler]掛到任何ChatOpenAI/ChatAnthropic/BaseChatModel上即可。 不用 subclass model、也不用 fork proxy。同一個 handler 連 LangGraph 也一起涵蓋, 因為 LangGraph 本來就是建在BaseChatModel之上。
Install
Section titled “Install”pnpm add @spendguard/sdk @spendguard/langchain @langchain/core @langchain/openai@spendguard/sdk 跟 @langchain/core 兩者都宣告成 peer dependency,所以這個
adapter 兩邊都不釘版本 —— 由你專案的 lockfile 決定。需要 Node 20.10+(底層
substrate 用到 await using 加上穩定版的 AsyncLocalStorage)。
Quick start
Section titled “Quick start”最小的端到端接線 —— 把一個 SpendGuardClient 連到 sidecar 的 UDS,交給
SpendGuardCallbackHandler,再把 handler 丟進 ChatOpenAI 的 callbacks:
陣列:
import { ChatOpenAI } from "@langchain/openai";import { HumanMessage } from "@langchain/core/messages";import { SpendGuardClient } from "@spendguard/sdk";import { SpendGuardCallbackHandler } from "@spendguard/langchain";
const client = new SpendGuardClient({ socketPath: "/var/run/spendguard/adapter.sock", tenantId: "00000000-0000-4000-8000-000000000001", runtimeKind: "langchain-js",});await client.connect();await client.handshake();
const handler = new SpendGuardCallbackHandler({ client, budgetId: "44444444-4444-4444-8444-444444444444",});
const model = new ChatOpenAI({ model: "gpt-4o-mini", callbacks: [handler],});
try { const res = await model.invoke([new HumanMessage("hello langchain")]); console.log(res.content);} finally { await client.close();}每次 model.invoke() 背後發生的事:
- LangChain 的
RunManager會在 OpenAI HTTP 呼叫之前先 firehandleChatModelStart。handler 從(tenantId, runId, parentRunId)推導出 canonical idempotency key,然後帶著trigger="LLM_CALL_PRE"呼叫client.reserve(...)。 - 一旦遇到
DecisionDenied(DENY / STOP / APPROVAL_REQUIRED),handler 會直接 throw ——raiseError = true是刻意接好的,讓這個 throw 經由CallbackManager往上傳,在供應商 HTTP 呼叫之前就把model.invoke()擋下來。 - 成功的話,這筆 inflight 的
(decisionId, reservationId)會以 LangChain 的runId當 key 暫存起來。接著 OpenAI HTTP 呼叫才發出去。 handleLLMEnd取出那筆 inflight entry,抽出供應商回報的tokenUsage,經由client.commitEstimated(...)送出一筆SUCCESScommit。handleLLMError則 對稱地送出PROVIDER_ERRORcommit,並把錯誤訊息串到actualErrorMessage上。
Configuration
Section titled “Configuration”handler 的選項介面支援把 unitId 直接傳遞下去(在 runner 環境裡設好
SPENDGUARD_UNIT_ID,再把它當成 unitId 選項傳進來)。設計上預留、比較豐富的
windowInstanceId / pricing / claimEstimator 等欄位,延到後面的 slice 才補。
| Option | Type | Required | Description |
|---|---|---|---|
client | SpendGuardClient | YES | 來自 @spendguard/sdk、已設定好的 substrate client。adapter 並不接管 client 的生命週期 —— connect() / handshake() / close() 都由使用端自己呼叫。 |
tenantId | string | NO | Tenant UUID 覆寫值。沒設的話,就用 client 在建構當下設定的那個 tenant。 |
budgetId | string | NO | 當成 projected claim 之 scopeId 用的 Budget UUID。沒設時,handler 會退回用 tenantId 當 scopeId。 |
之後的某個 minor 版(v0.x)會把這個介面逐欄位補齊,對齊 Python 的
SpendGuardChatModel 建構子(windowInstanceId、unit、pricing、
claimEstimator、route、callSignatureFn、claimEstimate、
onApprovalRequired)。這些擴充都是新增的 optional 欄位 —— 對 v0.1.0 的形狀
不會造成 breaking change。
Limitations
Section titled “Limitations”SpendGuard 的 LangChain.js adapter 只做 callback-based 的 pre-call 加上 end-of-stream commit。 handler 在上游供應商 HTTP 呼叫之前做 gate,在 串流跑完之後做 commit。在你把它擺到長串流回應前面、或想倚賴 D04/5 完整 設計介面之前,有幾件重要的事得先知道:
- 架構是 callback-based,不是 model subclass。 LangChain.js 偏好 callback
handler;所以 SpendGuard 出的是
BaseCallbackHandler,而不是包一層BaseChatModelsubclass。throw-to-halt 這套機制倚賴raiseError = true跟awaitHandlers = true—— 兩個都釘死在 handler instance 上。你要是在下游把它們改掉,預算 gate 就不靈了。 raiseError = true的傳遞是 load-bearing 的。 少了它,CallbackManager會把handleChatModelStart丟出的 throw 吞掉,預算 gate 就再也擋不住 LLM 呼叫。adapter 之所以明確把它釘住,就是為了防未來@langchain/core漂掉;千萬不要在 handler instance 上覆寫它。- 串流回應沒有 mid-stream cap。 SpendGuard 在 pre-call 階段 reserve 的是
predicted 預算。commit 是在 end-of-stream、對著真實的
tokenUsage做的。 handler 從不會中途把跑到一半的串流拆掉來阻止 token 繼續吐 —— 超用的部分 會落進稽核鏈、並在 commit 時反映出來,但那些 token 早就吐出去了。 - DEGRADE 的 patch 套用會以
MutationApplyFailed形式浮現。 跟 Pythonspendguard-sdk[langchain]v0.5.1 的立場一致。內建的 claim mutation 留到 後面的 slice 才做。 - 沒有 tool-call 的 mid-loop gating。 每次 tool call 都是它自己的 LangChain 事件;substrate 是逐次 call 處理 PRE/POST。跨 tool 的預算強制是 contract 層的責任,不是 adapter 的。
附帶的 docker-compose demo 會用一個真的 ChatOpenAI + sidecar + counting-stub 供應商,
端到端跑完整的 ALLOW + DENY + STREAM 三種情境來驗證:
make demo-up DEMO_MODE=langchain_ts這個 mode 會啟起 postgres + sidecar + langchain-runner + counting-stub,接著
從位於
examples/langchain-ts/
的 Node 範例跑三次 invocation:
- ALLOW —— 預算內的小訊息。
handleChatModelStartfire PRE,client.reserve()回 ALLOW,ChatOpenAIHTTP 呼叫發出去,counter +1,handleLLMEnd帶著真實 token 用量送出一筆SUCCESScommit。 - DENY ——
spendguard_estimate_override=2000000000直接衝破預先種好的 1B 硬上限。sidecar 的 contract evaluator 送出SPENDGUARD_DENY,client.reserve()丟出DecisionDenied,model.invoke()在 OpenAI HTTP 呼叫之前就停掉。counting-stub 的 counter 維持不變(證明 gate 是在 pre-call 階段 fire 的)。 - STREAM ——
streaming: true走的是 SSE chunked 路徑。PRE 在串流開啟時 fire 一次,POST 在串流結束時對著真實的usage.completion_tokenscommit 一次。
乾淨跑完的成功訊息:
[demo] langchain_ts ALL 3 steps PASS (ALLOW + DENY + STREAM)完整細節和 verify-SQL gate 都在
deploy/demo/langchain_ts/README.md。
Troubleshooting
Section titled “Troubleshooting”常見的啟動階段和第一次呼叫時會遇到的錯誤。substrate 那些 typed exception 都從
@spendguard/langchain 重新 export 出來了,所以你可以直接 pattern-match
DecisionDenied / SidecarUnavailable,不用再多一個 import。
-
DecisionDenied: reserve() denied by contract—— 預算用完、或某條 contract 規則送出SPENDGUARD_DENY時的預期行為。handler 會 rethrow,model.invoke()停掉,OpenAI HTTP 呼叫完全不會 fire。去 sidecar log 查是 哪條規則命中;ledger 裡那筆 decision row 的decision_context.contract_rule會帶著它。 -
預算 gate 沒擋住 —— 供應商呼叫還是發出去了。 幾乎都是因為呼叫路徑上某處
raiseError = false(可能是 handler instance 上被覆寫,或外層包了一個會把 它剝掉的CallbackManager)。adapter 在建構時就把raiseError = true跟awaitHandlers = true釘住;兩個都不要覆寫。設LANGCHAIN_VERBOSE=true就能重現 ——CallbackManager會把被吞掉的那個 throw log 出來。 -
SidecarUnavailable: handshake timeout—— sidecar 的 UDS 路徑 (SPENDGUARD_SIDECAR_UDS,預設/var/run/spendguard/adapter.sock)連不上。 確認 sidecar 容器有起來、socket 檔存在,而且你的 Node process 對它有 讀寫權限。在 demo overlay 裡這是由deploy/demo/langchain_ts/docker-compose.yaml接好的;在 demo 之外,把同一個 socket 路徑掛進你 Node 容器的/var/run/spendguard/。 -
handleLLMEnd: no inflight entry for runId=...—— 當 adapter 看到某個runId的 POST commit、卻沒有對應的 inflight reservation 時,會 log 一行 warn 然後 return。在降級情境下這是無害的(PRE 階段遇到SidecarUnavailable—— adapter 在沒 gate 的情況下放 LLM 呼叫過去)。如果你 看到它前面沒有SidecarUnavailable,那就要懷疑是框架重複送了一次handleLLMEnd—— substrate 的 in-processDecisionCache會在 commit 時把 重複的那筆收掉。 -
TypeError: client.reserve is not a function—— 你 import 的@spendguard/langchain配到的是reserve()改名之前的@spendguard/sdk版本。v0.1.0 的 adapter 需要@spendguard/sdk@^0.1.0。用pnpm add @spendguard/sdk@latest升上去。
Related
Section titled “Related”- Quickstart —— 5 分鐘把整套 SpendGuard stack 跑起來
- LangChain & LangGraph (Python) —— 這個 adapter 的 Python 姊妹版
- Contract YAML reference —— 撰寫 allow/stop 規則
- 其他 adapter 整合:Pydantic-AI · OpenAI Agents SDK · Microsoft AGT · LiteLLM proxy guardrail