미들웨어
미들웨어를 사용하면 구성부터 스트리밍, 도구 실행, 사용량 추적 및 완료까지 chat() 수명 주기의 모든 단계에 훅을 연결할 수 있습니다. 어댑터나 도구 구현을 수정하지 않고도 각 단계의 동작을 관찰하거나 변환하거나 중단할 수 있습니다.
일반적인 사용 사례는 다음과 같습니다.
- 로깅 및 관찰 가능성 — 토큰 사용량, 도구 실행 시간 및 오류를 추적합니다.
- 구성 변환 — 시스템 프롬프트를 주입하고, 반복마다 temperature를 조정하며, 도구를 필터링합니다.
- 스트림 처리 — 민감한 콘텐츠를 가리고, 청크를 변환하며, 원하지 않는 이벤트를 삭제합니다.
- 도구 호출 가로채기 — 인수를 검증하고, 결과를 캐시하며, 위험한 호출을 중단합니다.
- 부수 효과 — 분석 데이터를 전송하고, 데이터베이스를 업데이트하며, 알림을 트리거합니다.
빠른 시작
chat() 함수에 미들웨어 배열을 전달합니다.
import { chat, type ChatMiddleware } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const logger: ChatMiddleware = {
name: "logger",
onStart: (ctx) => {
console.log(`[${ctx.requestId}] Chat started`);
},
onFinish: (ctx, info) => {
console.log(`[${ctx.requestId}] Finished in ${info.duration}ms`);
},
};
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages: [{ role: "user", content: "Hello" }],
middleware: [logger],
});
개발 중에 미들웨어를 통과하는 청크를 확인하고 싶으신가요?
chat()호출에debug: { middleware: true }를 사용하면 됩니다 — 사용자 지정 미들웨어가 필요하지 않습니다. 디버그 로깅을 참조하세요.
수명 주기 개요
모든 chat() 호출은 예측 가능한 수명 주기를 따릅니다. 미들웨어 훅은 특정 단계에서 실행됩니다.
graph TD
A["chat() called"] --> B["onConfig (phase: init)"]
B --> C[onStart]
C --> D["onConfig (phase: beforeModel)"]
D --> E["Adapter streams response"]
E --> F["onChunk (for each chunk)"]
F --> G{Tool calls?}
G -->|No| H[onUsage]
G -->|Yes| I[onBeforeToolCall]
I --> J[Tool executes]
J --> K[onAfterToolCall]
K --> L{Continue loop?}
L -->|Yes| D
L -->|No| H
H --> SO{"Structured output path?"}
SO -->|None| M{Outcome}
SO -->|"Native combined"| SOH["Post-loop structured-output harvest (onChunk)"]
SOH --> M
SO -->|"Separate finalization"| SOC[onStructuredOutputConfig]
SOC --> SOM["onConfig (phase: structuredOutput)"]
SOM --> SOS["Structured-output finalization (onChunk, onUsage)"]
SOS --> M
M -->|Success| N[onFinish]
M -->|Abort| O[onAbort]
M -->|Error| P[onError]
style I fill:#e1f5ff
style J fill:#ffe1e1
style SOC fill:#e1f5ff
style SOM fill:#e1f5ff
style SOS fill:#e1f5ff
style SOH fill:#e1f5ff
style N fill:#e1ffe1
style O fill:#fff4e1
style P fill:#ffe1e1
단계 전환
컨텍스트의 phase 필드는 수명 주기에서 현재 위치를 추적합니다.
| 단계 | 시점 | 호출되는 훅 |
|---|---|---|
init | 시작 시 한 번 | onConfig |
beforeModel | 각 모델 호출 전 (각 반복마다) | onConfig |
modelStream | 어댑터가 청크를 스트리밍하는 동안 | onChunk, onUsage |
beforeTools | 도구 실행 전 | onBeforeToolCall |
afterTools | 도구 실행 후 | onAfterToolCall |
structuredOutput | 최종 구조화된 출력 어댑터 호출 중입니다(outputSchema가 설정되어 있고 현재 모델/옵션에 대해 supportsCombinedToolsAndSchema()가 true를 반환하지 않는 경우). adapter.structuredOutputStream의 청크(또는 생성된 비스트리밍 대체 경로)는 이 단계에서 onChunk를 거치며, 최종 호출의 토큰에 대해 onUsage가 실행됩니다. 실행되지 않는 경우는 도구와 스키마를 하나의 스트리밍 호출에서 기본적으로 결합하는 어댑터입니다(최신 OpenAI Chat Completions, OpenAI Responses, Claude 4.5+, Gemini 3.x, Grok 4.x 제품군 및 라우팅된 모든 모델이 OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS에 포함된 경우의 OpenRouter이며, issue #605를 참조하세요). 이 경로에서 미들웨어는 평소처럼 beforeModel / modelStream을 통해 실행을 관찰합니다. | onStructuredOutputConfig, onConfig, onChunk, onUsage |
훅 참조
onConfig
init(시작) 중 한 번, beforeModel(각 모델 호출 전) 중 반복마다 한 번 실행됩니다. 별도 최종화 경로에서는 구조화된 출력 경계에서 ctx.phase === 'structuredOutput'인 상태로 onConfig가 추가로 다시 실행되며, onStructuredOutputConfig 이후의 구성 뷰를 받습니다. 따라서 단일 반복의 별도 최종화 실행에서는 onConfig가 세 번 실행됩니다(init + beforeModel + structuredOutput). 네이티브 결합 출력에서는 이 세 번째 호출이 추가되지 않습니다. 모델이 받는 구성을 변환하려면 onConfig를 사용합니다.
변경하려는 필드만 포함한 부분 구성 객체를 반환합니다. 현재 구성과 자동으로 얕게 병합되므로 기존 구성을 전개할 필요가 없습니다.
import { type ChatMiddleware } from "@tanstack/ai";
const dynamicTemperature: ChatMiddleware = {
name: "dynamic-temperature",
onConfig: (ctx, config) => {
if (ctx.phase === "init") {
// Add a system prompt at startup — only systemPrompts is overwritten
return {
systemPrompts: [
...config.systemPrompts,
"You are a helpful assistant.",
],
};
}
if (ctx.phase === "beforeModel" && ctx.iteration > 0) {
// Increase temperature on retries. Sampling params live in the
// provider-native modelOptions object — `temperature` is universal,
// so it's the same key across providers. Spread the existing
// modelOptions so other model options stay unchanged.
const current =
typeof config.modelOptions?.temperature === "number"
? config.modelOptions.temperature
: 0.7;
return {
modelOptions: {
...config.modelOptions,
temperature: Math.min(current + 0.1, 1.0),
},
};
}
},
};
샘플링 매개변수(
temperature,top_p/topP, 다양한max*Tokens키)는 각 제공자의 네이티브 이름으로modelOptions내부에 있으며, 더 이상 루트 구성 필드가 아닙니다. 모든 제공자에서temperature의 표기가 우연히 같으므로 위 예제는 제공자에 종속되지 않습니다. 대신 토큰 제한을 변경한다면 제공자 네이티브 키를 사용합니다(예: OpenAI의max_output_tokens, Ollama에서modelOptions.options아래에 중첩된num_predict). 샘플링 옵션을 modelOptions로 이동을 참조하세요.
변환할 수 있는 구성 필드:
| 필드 | 타입 | 설명 |
|---|---|---|
messages | ModelMessage[] | 표준 대화 기록입니다. 영속성과 ctx.messages가 이 필드를 사용합니다. |
providerMessages | ModelMessage[] | 제공자에게 보내는 임시 컨텍스트입니다. 기본값은 messages입니다. |
systemPrompts | string[] | 시스템 프롬프트 |
tools | Tool[] | 사용 가능한 도구 |
metadata | Record<string, unknown> | 요청 메타데이터 |
modelOptions | Record<string, unknown> | 제공자 네이티브 옵션입니다. 샘플링 매개변수(temperature, top_p / topP, 제공자의 max*Tokens 키)를 비롯한 모든 모델별 옵션이 여기에 있습니다. 샘플링 옵션을 modelOptions로 이동을 참조하세요. |
여러 미들웨어가 onConfig를 정의하면 구성이 순서대로 미들웨어를 통과하며, 각 미들웨어는 이전 미들웨어에서 병합된 구성을 받습니다.
변환이 모델 호출에만 영향을 줘야 할 때는 providerMessages를 반환합니다. 호환성을 위해 동일한 결과에서 providerMessages를 명시적으로 설정하지 않는 한 messages를 반환해도 제공자 입력이 업데이트됩니다.
onStructuredOutputConfig
최종 구조화된 출력 어댑터 호출 시작 시 한 번 실행되며, outputSchema와 함께 chat()이 호출되고 현재 모델/옵션에 대해 supportsCombinedToolsAndSchema()가 true를 반환하지 않는 경우에만 실행됩니다. onConfig와 마찬가지로 미들웨어를 순서대로 통과하지만, 제공자에게 전송되는 JSON 스키마에 접근할 수 있습니다. 스키마를 변환하거나(예: $defs 주입, 제공자 호환되지 않는 키워드 제거) 구조화된 출력 전용 동작을 적용해야 할 때(예: 최종 호출에서 시스템 프롬프트 억제) 이 훅을 사용합니다.
네이티브 결합 어댑터(최신 OpenAI, Claude 4.5+, Gemini 3.x, Grok 4.x — issue #605 참조)는 별도의 최종화 호출을 건너뛰며 이 훅을 호출하지 않습니다. 엔진은
onConfig실행 후 변환된 스키마를chatStream에 직접 전달하므로 미들웨어가 네이티브 결합 스키마를 변환할 수 없습니다.
변경하려는 필드만 포함한 부분 StructuredOutputMiddlewareConfig를 반환합니다. 현재 구성과 얕게 병합됩니다. 그대로 통과시키려면 void를 반환합니다.
import { type ChatMiddleware } from "@tanstack/ai";
import { sharedDefs } from "./defs";
const injectDefs: ChatMiddleware = {
name: "inject-defs",
onStructuredOutputConfig: (_ctx, config) => {
// `config.outputSchema` is the JSON Schema being sent to the provider
return {
outputSchema: {
...config.outputSchema,
$defs: { ...sharedDefs },
},
};
},
};
변환할 수 있는 구성 필드:
| 필드 | 타입 | 설명 |
|---|---|---|
messages | ModelMessage[] | 표준 대화 이력 |
providerMessages | ModelMessage[] | 최종 호출에 전송되는 임시 컨텍스트 |
systemPrompts | SystemPrompt[] | 최종 호출의 시스템 프롬프트 |
metadata | Record<string, unknown> | 요청 메타데이터 |
modelOptions | Record<string, unknown> | Provider-native 옵션 — 샘플링 매개변수(temperature, top_p / topP, provider의 max*Tokens 키)가 이제 여기에 있으며, 기타 모든 모델별 설정도 함께 위치합니다. 샘플링 옵션을 modelOptions로 이동하기를 참조하세요. |
outputSchema | JSONSchema | 구조화된 출력을 위해 제공자에게 전송되는 JSON 스키마 |
구조화된 출력 경계에서의 순서:
onStructuredOutputConfig가 먼저 실행되며 배열 순서에 따라 모든 미들웨어를 통과합니다.- 그런 다음 동일한 경계에서
ctx.phase === 'structuredOutput'인 상태로onConfig가 다시 실행되며,onStructuredOutputConfig이후의 구성 뷰(outputSchema제외)를 받습니다. 모든 어댑터 호출에 적용되는 범용 변환에는onConfig를 사용하고, 스키마에 접근해야 할 때는onStructuredOutputConfig를 사용합니다.
여러 미들웨어가 onStructuredOutputConfig를 정의하면 구성이 순서대로 미들웨어를 통과하며, 각 미들웨어는 이전 미들웨어에서 병합된 구성을 받습니다.
onStart
초기 onConfig가 완료된 후 한 번 실행됩니다. 타이머 초기화나 로깅 같은 설정 작업에 사용합니다.
import { type ChatMiddleware } from "@tanstack/ai";
const timer: ChatMiddleware = {
name: "timer",
onStart: (ctx) => {
console.log(`Request ${ctx.requestId} started at iteration ${ctx.iteration}`);
},
};
onChunk
어댑터에서 스트리밍되는 모든 청크에 대해 실행됩니다. 청크를 관찰하거나 변환하거나 확장하거나 삭제할 수 있습니다.
import { type ChatMiddleware } from "@tanstack/ai";
const redactor: ChatMiddleware = {
name: "redactor",
onChunk: (ctx, chunk) => {
if (chunk.type === "TEXT_MESSAGE_CONTENT") {
// Transform: redact sensitive content
return {
...chunk,
delta: chunk.delta.replace(/\b\d{3}-\d{2}-\d{4}\b/g, "[REDACTED]"),
};
}
// Return void to pass through unchanged
},
};
반환 값:
| 반환 | 효과 |
|---|---|
void / undefined | 청크를 변경하지 않고 통과시킵니다 |
StreamChunk | 원래 청크를 대체합니다 |
StreamChunk[] | 여러 청크로 확장합니다 |
null | 청크를 완전히 삭제합니다 |
여러 미들웨어가 onChunk를 정의하면 청크가 순서대로 미들웨어를 통과합니다. 한 미들웨어가 청크를 삭제(null 반환)하면 이후 미들웨어는 이를 볼 수 없습니다.
확인할 수 있는 청크 유형
onChunk는 실행에서 생성되는 모든 AG-UI 이벤트를 받으며 텍스트만 받는 것이 아닙니다. 타입별 필드를 읽기 전에 판별된 유니온인 chunk.type으로 범위를 좁힙니다. 일반적인 이벤트는 다음과 같습니다.
chunk.type | 의미 | 주요 필드 |
|---|---|---|
RUN_STARTED / RUN_FINISHED / RUN_ERROR | 실행 수명 주기 경계 | runId, finishReason, 완료 시 usage, 오류 시 message |
TEXT_MESSAGE_START / TEXT_MESSAGE_CONTENT / TEXT_MESSAGE_END | 어시스턴트 텍스트 스트리밍 | messageId, 콘텐츠인 delta |
TOOL_CALL_START / TOOL_CALL_ARGS / TOOL_CALL_END | 도구 호출 스트리밍 | toolCallId, toolCallName, 인수인 delta, 종료 시 결과 |
STEP_STARTED / STEP_FINISHED | 사고/추론 단계 | delta, signature |
STATE_SNAPSHOT / STATE_DELTA | 에이전트 상태 동기화 | snapshot, delta |
CUSTOM | 확장성 이벤트(아래의 구조화된 출력 포함) | name, value |
전체 이벤트 카탈로그와 정확한 필드 형태는 AG-UI 프로토콜 문서를 참조하세요.
구조화된 출력 청크 변환
별도의 onStructuredOutputChunk 훅은 없으며, 필요하지도 않습니다. outputSchema와 함께 chat()이 호출되면 구조화된 출력 청크(JSON TEXT_MESSAGE_CONTENT 델타, structured-output.start / structured-output.complete CUSTOM 이벤트 및 모든 최종화 RUN_ERROR)가 다른 모든 청크와 동일한 onChunk 훅을 통과합니다. 다른 청크와 똑같이 변환하거나 확장하거나 삭제합니다.
이를 구분하는 방법은 어댑터가 사용하는 최종화 경로에 따라 달라집니다.
- 별도 최종화 어댑터(현재 모델/옵션에 대해
supportsCombinedToolsAndSchema()가true를 반환하지 않음): 최종화 호출 중ctx.phase === 'structuredOutput'입니다. 단계를 기준으로 구분합니다. - 네이티브 결합 어댑터(최신 OpenAI Chat Completions / Responses, Claude 4.5+, Gemini 3.x, Grok 4.x — issue #605 참조): 스키마 제약 JSON이 모델의 자연스러운 최종 차례에 생성되므로
ctx.phase는'modelStream'으로 유지됩니다.'structuredOutput'단계는 실행되지 않습니다. 대신 CUSTOM 이벤트 이름(structured-output.start/structured-output.complete)을 기준으로 구분합니다.
import { type ChatMiddleware } from "@tanstack/ai";
const redactStructuredOutput: ChatMiddleware = {
name: "redact-structured-output",
onChunk: (ctx, chunk) => {
// Separate-finalization path: the JSON streams as TEXT_MESSAGE_CONTENT
// during the 'structuredOutput' phase. Transform the delta like any
// other text chunk — here, redact anything that looks like an SSN before
// it reaches the client.
if (
ctx.phase === "structuredOutput" &&
chunk.type === "TEXT_MESSAGE_CONTENT"
) {
return {
...chunk,
delta: chunk.delta.replace(/\b\d{3}-\d{2}-\d{4}\b/g, "[REDACTED]"),
};
}
// Both paths: the completed typed payload arrives as a CUSTOM
// `structured-output.complete` event. On the native-combined path this is
// your only signal (ctx.phase never flips to 'structuredOutput'), so key
// off the event name, not the phase. `chunk.value` carries { object, raw }.
if (chunk.type === "CUSTOM" && chunk.name === "structured-output.complete") {
console.log("final structured output:", chunk.value);
}
// Return void to pass everything else through unchanged.
},
};
onStructuredOutputConfig는 있지만onStructuredOutputChunk가 없는 이유는 무엇인가요? 구조화된 출력 경계에서는 구성 형태가 실제로 다르기 때문입니다. 일반ChatMiddlewareConfig에는 없는outputSchema필드를 포함합니다(onStructuredOutputConfig 참조). 청크는 단계와 관계없이 모두StreamChunk이므로 하나의onChunk와ctx.phase(또는 CUSTOM 이벤트 이름)로 모든 경우를 처리할 수 있으며, 별도의 청크 훅은 중복됩니다.
onShouldContinue
엔진이 다른 에이전트 루프 반복을 시작할지 결정할 때(도구 단계 후 또는 모델 차례 사이) 호출됩니다. 미들웨어 간 AND 의미 및 agentLoopStrategy와 결합되며, 명시적인 false는 루프를 중지합니다. 계속하려면 true, void 또는 undefined를 반환합니다.
실행을 중단하지 않습니다. 스트림은 현재 메시지와 함께 정상적으로 완료됩니다. 강제 중단에만 ctx.abort()를 사용합니다.
import { type ChatMiddleware } from "@tanstack/ai";
const budget: ChatMiddleware = {
name: "tool-budget",
onShouldContinue: (_ctx, state) => {
// Stop further turns once 20 tool calls have been emitted
if (state.toolCallCount >= 20) return false;
},
};
턴별 및 누적 도구 예산 레시피는 도구 호출 예산을 참조하세요.
onInterruptBoundary 및 onInterruptResolution
미들웨어가 클라이언트의 데이터를 필요로 할 때 이 훅을 사용합니다. defineInterrupt()로 요청을 정의하고 chat({ interrupts }) 및 useChat({ interrupts })에 등록합니다. 미들웨어에서 원시 AG-UI 이벤트를 내보내지 마세요.
onInterruptBoundary 는 에이전트 반복에서 네 가지 지점에서 실행됩니다:
beforeModel, 어댑터가 시작되기 전.afterModel, 모델 응답이 완료된 후.beforeTools, 도구 실행이 시작되기 전.afterTools, 도구 단계가 완료된 후.
각 미들웨어는 하나의 경계에서 요청을 반환할 수 있습니다. 엔진은 해당 경계의 모든 요청을 하나의 AG-UI 인터럽트 배치로 결합합니다. 배치는 하나의 인터럽트 결과와 함께 실행을 종료합니다.
이 훅은 구성을 변경할 수 없습니다. 허용되는 반환값은 { interrupts } 또는 없음뿐입니다. 계속 실행은 새로운 chat() 호출이므로 훅이 다시 실행됩니다. 이 일시 중지가 원래 요청에만 속한다면 ctx.parentRunId가 설정된 경우 이벤트를 내보내지 않습니다.
각 단계에서 ctx에 포함되는 내용과 각 단계를 사용하는 시점은 수명 주기 경계에 설명되어 있습니다.
공유 정의 하나를 만듭니다. 서버와 클라이언트가 모두 이 값을 가져오므로 양쪽의 정의 ID와 응답 형태가 동일하게 유지됩니다.
import { defineInterrupt, type ChatMiddleware } from '@tanstack/ai'
import { z } from 'zod'
export const reviewPlan = defineInterrupt({
id: 'review-plan',
payloadSchema: z.object({ title: z.string() }),
responseSchema: z.object({ approved: z.boolean() }),
})
export const reviewMiddleware: ChatMiddleware<unknown, typeof reviewPlan> = {
name: 'review-plan',
onInterruptBoundary(ctx) {
if (ctx.phase !== 'beforeTools') return
if (ctx.parentRunId) return
return {
interrupts: [
reviewPlan.interrupt({
key: 'release-plan',
reason: 'review-required',
message: 'Approve this plan?',
payload: { title: 'Release plan' },
}),
],
}
},
onInterruptResolution(_ctx, resumedInterrupts) {
for (const result of resumedInterrupts.for(reviewPlan)) {
if (result.status === 'resolved' && !result.response.approved) {
return { toolResume: 'stop' }
}
}
},
}
서버에 정의를 등록합니다. 클라이언트의 해결이 전체 컨텍스트로 계속 실행을 시작하도록 parentRunId와 resume을 전달합니다.
import {
chat,
chatParamsFromRequestBody,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { reviewMiddleware, reviewPlan } from './review-plan'
export async function POST(request: Request) {
const params = await chatParamsFromRequestBody(await request.json())
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
...(params.parentRunId ? { parentRunId: params.parentRunId } : {}),
...(params.resume ? { resume: params.resume } : {}),
interrupts: [reviewPlan],
middleware: [reviewMiddleware],
})
return toServerSentEventsResponse(stream)
}
클라이언트에도 동일한 정의를 등록합니다. kind와 definitionId를 확인합니다. 그러면 TypeScript가 항목을 GenericInterrupt<typeof reviewPlan>으로 취급합니다. resolveInterrupt는 reviewPlan.responseSchema의 응답 형태를 사용합니다.
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
import type { GenericInterrupt } from '@tanstack/ai-react'
import { reviewPlan } from './review-plan'
function ReviewCard({
interrupt,
}: {
interrupt: GenericInterrupt<typeof reviewPlan>
}) {
return (
<button
onClick={() => interrupt.resolveInterrupt({ approved: true })}
>
Approve plan
</button>
)
}
export function ReviewPlanPanel() {
const { interrupts, sendMessage } = useChat({
connection: fetchServerSentEvents('/api/chat'),
interrupts: [reviewPlan],
})
return (
<>
<button onClick={() => sendMessage('Review the release plan')}>
Start review
</button>
{interrupts.map((interrupt) => {
if (interrupt.kind !== 'generic') return null
if (!('definitionId' in interrupt)) return null
if (interrupt.definitionId !== reviewPlan.id) return null
return <ReviewCard key={interrupt.id} interrupt={interrupt} />
})}
</>
)
}
일시 중지된 chat() 호출에서는 onInterruptResolution이 실행되지 않습니다. 이 훅은
클라이언트가 응답한 후 다음 chat() 호출 시작 시 한 번 실행됩니다.
setup
onConfig (phase is init)
onInterruptResolution (phase is still init)
onStart
then stop, or continue the agent loop
useChat은 두 번째 요청에 parentRunId와 resume을 보냅니다. 각 제네릭 재개 항목에는 metadata에 원래 요청이 포함됩니다. resume이 있고 parentRunId가 없으면 서버가 예외를 발생시킵니다.
resumedInterrupts.for(definition) 를 하나의 타입 정의에 사용합니다. resumedInterrupts.all() 을 등록한 모든 정의에 사용합니다. resumedInterrupts.all(definitionA, definitionB) 를 타입화된 부분집합을 읽기 위해 사용합니다.
훅은 toolResume: 'continue', 'cancel' 또는 'stop'을 반환할 수 있습니다. 모든 미들웨어의 결과는 가장 제한적인 규칙으로 결합됩니다. stop이 cancel보다 우선하고 cancel이 continue보다 우선합니다.
이 훅은 프롬프트, 도구 또는 메시지를 변경할 수 없습니다. 답변을 capability에 저장한 다음 ctx.phase === 'beforeModel'일 때 onConfig에서 해당 필드를 반환합니다.
| 훅 | 변경 가능 항목 |
|---|---|
onInterruptBoundary | 없음. 일시 중지만 수행할 수 있습니다. |
onInterruptResolution | Pending-tool 정책 (toolResume) |
onConfig | messages, systemPrompts, tools, modelOptions, metadata |
전체 재개 순서와 사용자 메모를 시스템 프롬프트에 작성하는 예제는 답변 적용에 있습니다.
onBeforeToolCall
각 도구가 실행되기 전에 호출됩니다. void가 아닌 결정을 반환하는 첫 번째 미들웨어가 단락 처리하므로 해당 도구 호출에서는 나머지 미들웨어를 건너뜁니다.
import { type ChatMiddleware } from "@tanstack/ai";
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null;
}
const guard: ChatMiddleware = {
name: "guard",
onBeforeToolCall: (ctx, hookCtx) => {
// Block dangerous tools
if (hookCtx.toolName === "deleteDatabase") {
return { type: "abort", reason: "Dangerous operation blocked" };
}
// Validate and transform arguments
if (hookCtx.toolName === "search" && isRecord(hookCtx.args) && !hookCtx.args.limit) {
return {
type: "transformArgs",
args: { ...hookCtx.args, limit: 10 },
};
}
},
};
결정 유형:
| 결정 | 효과 |
|---|---|
void / undefined | 정상적으로 계속 진행, 다음 미들웨어가 결정할 수 있음 |
{ type: 'transformArgs', args } | 실행 전에 도구 인수를 대체 |
{ type: 'skip', result } | 실행을 완전히 건너뛰고 제공된 결과를 사용 |
{ type: 'abort', reason? } | 전체 채팅 실행을 중단 |
The hookCtx 제공합니다:
| 필드 | 타입 | 설명 |
|---|---|---|
toolCall | ToolCall | 원본 도구 호출 객체 |
tool | Tool | undefined | 해결된 도구 정의 |
args | unknown | 파싱된 인자 |
toolName | string | 도구 이름 |
toolCallId | string | 도구 호출 ID |
onAfterToolCall
각 도구 실행(또는 건너뛰기) 후 호출됩니다. 모든 미들웨어가 실행되며 단락 처리는 없습니다.
import { type ChatMiddleware } from "@tanstack/ai";
const toolLogger: ChatMiddleware = {
name: "tool-logger",
onAfterToolCall: (ctx, info) => {
if (info.ok) {
console.log(`${info.toolName} completed in ${info.duration}ms`);
} else {
console.error(`${info.toolName} failed:`, info.error);
}
},
};
info 객체는 다음을 제공합니다.
| 필드 | 타입 | 설명 |
|---|---|---|
toolCall | ToolCall | 원본 도구 호출 객체 |
tool | Tool | undefined | 해결된 도구 정의 |
toolName | string | 도구 이름 |
toolCallId | string | 도구 호출 ID |
ok | boolean | 실행 성공 여부 |
duration | number | 밀리초 단위 실행 시간 |
result | unknown | ok가 true일 때의 결과 |
error | unknown | ok가 false일 때의 오류 |
onUsage
RUN_FINISHED 청크에 사용량 데이터가 포함될 때 모델 반복마다 한 번 호출됩니다. 사용량 객체를 직접 받습니다.
import { type ChatMiddleware } from "@tanstack/ai";
const usageTracker: ChatMiddleware = {
name: "usage-tracker",
onUsage: (ctx, usage) => {
console.log(
`Iteration ${ctx.iteration}: ${usage.totalTokens} tokens`
);
},
};
usage 객체는 다음과 같습니다.
| 필드 | 타입 | 설명 |
|---|---|---|
promptTokens | number | 입력 토큰 |
completionTokens | number | 출력 토큰 |
totalTokens | number | 총 토큰 |
종료 훅: onFinish, onAbort, onError
chat() 호출마다 종료 훅이 정확히 하나 실행됩니다. 서로 배타적입니다.
| 훅 | 실행 시점 |
|---|---|
onFinish | 실행이 정상적으로 완료됨 |
onAbort | 실행이 중단됨(ctx.abort(), 외부 AbortSignal 또는 onBeforeToolCall의 { type: 'abort' } 결정) |
onError | 처리되지 않은 오류가 발생함 |
별도 최종화 경로: 네이티브 결합을 지원하지 않는 어댑터는 에이전트 루프 후 별도의 구조화된 출력 제공자 호출을 수행합니다.
onStructuredOutputConfig는 별도 제공자 호출 전에 실행되며 해당 청크의ctx.phase는'structuredOutput'입니다.- 최종화에서는
onIteration이 실행되지 않으며, 에이전트 루프 반복에서만 실행됩니다.- 최종화가 완료된 후
onFinish가 실행됩니다.info객체에는 에이전트 루프의 종료 상태가 반영됩니다.info.content— 에이전트 루프에서 누적된 텍스트입니다. 별도 최종화 JSON 델타는 포함되지 않습니다. 미들웨어는onChunk의structured-output.completeCUSTOM 이벤트를 통해 완료된 결과를 관찰할 수 있습니다.info.usage— 에이전트 루프의 마지막RUN_FINISHED.usage입니다. 도구 없는 구조화된 출력 실행(에이전트 루프 반복에서RUN_FINISHED가 생성되지 않음)에서는undefined입니다. 최종화 토큰을 수집하려면onUsage를 사용합니다. 이 훅은 최종화 호출을 포함해 사용량이 포함된 모든RUN_FINISHED에 대해 실행됩니다.info.finishReason— 에이전트 루프의 마지막finishReason입니다. 에이전트 루프 반복에서RUN_FINISHED가 생성되지 않으면null입니다(예: 도구 없는 구조화된 출력 실행).info.duration— 최종화를 포함한 전체chat()호출의 실제 경과 시간입니다.네이티브 결합 출력: 네이티브 결합을 지원하는 어댑터는 일반 에이전트 루프 스트림에서 스키마 제약 JSON을 생성합니다.
onStructuredOutputConfig는 실행되지 않고ctx.phase는'modelStream'으로 유지되며 JSON을 생성하는 반복에서onIteration이 실행됩니다. JSON은 에이전트 루프 텍스트이므로info.content에 포함됩니다. 미들웨어는 같은 단계에서onChunk의structured-output.complete이벤트를 관찰합니다.어느 경로에서든 성공적으로 완료되면
onFinish는ctx.messages에서 완전한 표준 트랜스크립트를 받습니다. 네이티브 결합 출력은 종료 어시스턴트 메시지에 구조화된 결과를 유지합니다. 별도 최종화 경로에서는 에이전트 루프의 일반 텍스트 어시스턴트 메시지 뒤에 별도의 구조화된 출력 어시스턴트 메시지를 보존할 수 있습니다. 이 트랜스크립트는info의 경로별 필드와 별개입니다.전체 실행의 사용량을 집계하려면
info.usage에 의존하지 말고onUsage콜백에서 누적합니다.
import { type ChatMiddleware } from "@tanstack/ai";
const terminal: ChatMiddleware = {
name: "terminal",
onFinish: (ctx, info) => {
console.log(`Finished: ${info.finishReason}, ${info.duration}ms`);
console.log(`Content: ${info.content}`);
if (info.usage) {
console.log(`Tokens: ${info.usage.totalTokens}`);
}
},
onAbort: (ctx, info) => {
console.log(`Aborted: ${info.reason}, ${info.duration}ms`);
},
onError: (ctx, info) => {
console.error(`Error after ${info.duration}ms:`, info.error);
},
};
onFinish의 info 객체(FinishInfo)는 다음과 같습니다.
| 필드 | 타입 | 설명 |
|---|---|---|
finishReason | string | null | 에이전트 루프의 마지막 finishReason입니다. 에이전트 루프 반복에서 null가 생성되지 않으면 RUN_FINISHED입니다(예: 도구 없는 chat({ outputSchema }) 실행). |
duration | number | 구조화된 출력 최종화를 포함한 전체 실행 시간(밀리초)입니다. |
content | string | 에이전트 루프에서 누적된 텍스트 콘텐츠입니다. 네이티브 결합 구조화 JSON은 포함하고 별도 최종화 JSON은 제외합니다. structured-output.complete를 통해 onChunk CUSTOM 이벤트로 완료된 결과를 관찰합니다. |
usage | { promptTokens; completionTokens; totalTokens } | undefined | 선택 사항입니다. 에이전트 루프의 마지막 RUN_FINISHED.usage입니다. 최종화 토큰은 포함하지 않습니다. 이를 관찰하려면 onUsage를 사용합니다. 항상 if (info.usage) 또는 info.usage?.로 확인합니다. |
컨텍스트 객체
모든 훅은 첫 번째 인수로 ChatMiddlewareContext를 받습니다. 요청 범위 정보와 제어 함수를 제공합니다.
| 필드 | 타입 | 설명 |
|---|---|---|
requestId | string | 이 채팅 요청의 고유 ID |
streamId | string | 이 스트림의 고유 ID |
threadId | string | AG-UI 스레드 식별자입니다. 호출자가 제공한 threadId(또는 기존 conversationId)로 확인되며, 둘 다 없으면 자동 생성됩니다. 이벤트 상관 관계에 사용합니다. |
conversationId | string | undefined | threadId의 지원 중단 예정 별칭입니다. 항상 ctx.threadId와 같으며, AG-UI 이름 변경 전에 작성된 미들웨어의 작동을 위해 유지됩니다. 새 미들웨어에서는 ctx.threadId를 읽어야 합니다. |
phase | ChatMiddlewarePhase | 현재 수명 주기 단계 |
iteration | number | 에이전트 루프 반복(0부터 시작) |
chunkIndex | number | 지금까지 생성된 청크 수 |
signal | AbortSignal | undefined | 외부 중단 시그널 |
abort(reason?) | function | 미들웨어 내부에서 실행을 중단합니다 |
emitCustomEvent(name, value) | function | 지금 채팅 스트림에 CUSTOM 청크를 추가합니다. 현재 훅이 실행 중이어도 엔진이 이를 생성하며 onConfig 중에도 동일합니다. |
context | TContext | 사용자가 제공한 런타임 컨텍스트 값 |
defer(promise) | function | 차단하지 않는 부수 효과를 등록합니다 |
타입화된 런타임 컨텍스트
ChatMiddleware는 컨텍스트 제네릭을 받습니다. 이를 통해 chat() 외부에서 선언한 재사용 가능한 미들웨어가 도구와 동일한 타입의 런타임 컨텍스트에 접근할 수 있습니다.
import { chat, type ChatMiddleware } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { session, audit } from "./server";
type AppContext = {
userId: string;
audit: {
write(event: { userId: string; requestId: string }): Promise<void>;
};
};
export const auditMiddleware: ChatMiddleware<AppContext> = {
name: "audit",
onStart(ctx) {
ctx.defer(
ctx.context.audit.write({
userId: ctx.context.userId,
requestId: ctx.requestId,
})
);
},
};
chat({
adapter: openaiText("gpt-5.5"),
messages: [{ role: "user", content: "Hello" }],
middleware: [auditMiddleware],
context: {
userId: session.user.id,
audit,
},
});
타입이 지정된 미들웨어나 도구가 있으면 chat()은 제공된 context가 필수 형태와 일치하는지 확인합니다. 일반 ChatMiddleware로 타입이 지정된 기존 미들웨어도 계속 작동하며, 해당 미들웨어의 ctx.context는 unknown으로 유지되고 context 옵션을 강제하지 않습니다.
런타임 컨텍스트는 프로세스 로컬 애플리케이션 상태입니다. 이는 chatParamsFromRequest가 파싱하는 프로토콜 메타데이터인 AG-UI RunAgentInput.context와 별개입니다. 서버, 클라이언트 및 클라이언트-서버 전달 패턴은 런타임 컨텍스트를 참조하세요.
미들웨어에서 중단하기
실행을 정상적으로 중지하려면 ctx.abort()를 호출합니다. 그러면 onAbort 종료 훅이 실행됩니다.
import { type ChatMiddleware } from "@tanstack/ai";
const timeout: ChatMiddleware = {
name: "timeout",
onChunk: (ctx) => {
if (ctx.chunkIndex > 1000) {
ctx.abort("Too many chunks");
}
},
};
지연된 부수 효과
스트림을 차단하지 않고 종료 훅 이후 실행할 프로미스를 등록하려면 ctx.defer()를 사용합니다.
import { type ChatMiddleware } from "@tanstack/ai";
const analytics: ChatMiddleware = {
name: "analytics",
onFinish: (ctx, info) => {
ctx.defer(
fetch("/api/analytics", {
method: "POST",
body: JSON.stringify({
requestId: ctx.requestId,
duration: info.duration,
tokens: info.usage?.totalTokens,
}),
})
);
},
};
여러 미들웨어 구성
미들웨어는 배열 순서대로 실행됩니다. 순서는 통과 또는 단락 처리하는 훅에서 중요합니다.
import { chat, type ChatMiddleware } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { authMiddleware, loggingMiddleware, cachingMiddleware } from "./middleware";
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages: [{ role: "user", content: "Hello" }],
middleware: [authMiddleware, loggingMiddleware, cachingMiddleware],
});
조합 규칙
| 훅 | 구성 방식 | 순서의 효과 |
|---|---|---|
onConfig | 파이프 처리 — 각각 이전 출력을 받음 | 앞선 미들웨어가 먼저 변환함 |
onStructuredOutputConfig | 파이프 처리 — 각각 이전 출력을 받음 | 앞선 미들웨어가 먼저 변환함 |
onStart | 순차 실행 | 모두 순서대로 실행됨 |
onChunk | 파이프 처리 — 청크가 각 미들웨어를 통과함 | 첫 번째 미들웨어가 청크를 삭제하면 이후 미들웨어는 이를 볼 수 없음 |
onBeforeToolCall | 선착순 적용 — 처음 반환된 void가 아닌 결정이 적용됨 | 앞선 미들웨어가 우선함 |
onShouldContinue | AND — 명시적인 false가 하나라도 있으면 루프를 중지함 | 단락 평가 시 어떤 미들웨어가 먼저 실행되는지만 순서의 영향을 받음 |
onAfterToolCall | 순차 실행 | 모두 순서대로 실행됨 |
onUsage | 순차 실행 | 모두 순서대로 실행됨 |
onFinish/onAbort/onError | 순차 실행 | 모두 순서대로 실행됨 |
기능
미들웨어는 상태를 공유해야 하는 경우가 많습니다. 제공자 미들웨어가 데이터베이스 핸들, 요청별 카운터 또는 샌드박스 등을 설정하면 소비자 미들웨어가 동일한 실행에서 나중에 이를 읽습니다. capability를 사용하면 이 전달을 타입 안전하고 순서가 검사되도록 만들 수 있습니다. 소비자는 필요한 항목을 선언하고 제공자는 제공하는 항목을 선언하며, 필수 capability가 제공되지 않으면 chat()은 컴파일 시점과 런타임 모두에서 실행을 거부합니다.
capability 생성
capability는 createCapability<TValue>()('name')이라는 커리된 호출로 생성합니다.
import { createCapability } from "@tanstack/ai";
const counterCapability = createCapability<{ value: number }>()("counter");
const [getCounter, provideCounter] = counterCapability;
커링은 의도적인 것입니다. 값 타입(<{ value: number }>)은 명시적으로 제공하고 이름 리터럴은 인수("counter")에서 추론합니다. 단일 createCapability<T>('name') 호출로는 둘 다 수행할 수 없습니다. T를 명시하면 TypeScript가 이름을 추론하지 못해 string으로 축소되며, 리터럴 이름을 기준으로 하는 컴파일 시점 범위 검사가 무력화됩니다.
반환된 counterCapability는 하이브리드 값입니다.
[get, provide]로 구조 분해되며, 훅 내부에서 사용하는 두 접근자입니다.requires/provides에 나열하는 식별자 자체입니다. 별도로 가져올 토큰은 없습니다.
접근자는 다음과 같습니다.
| 접근자 | 동작 |
|---|---|
getCounter(ctx) | 값을 반환합니다. capability가 제공되지 않았으면 예외를 발생시킵니다. |
getCounter(ctx, { optional: true }) | TValue | undefined를 반환합니다. 없을 때 예외를 발생시키지 않습니다. |
provideCounter(ctx, value) | 이 실행에 값을 설정합니다. setup에서 호출합니다. |
동일하게 컨텍스트는 ctx.get(capability), ctx.getOptional(capability) 및 ctx.provide(capability, value)를 노출하므로 capability 핸들을 직접 전달할 수 있습니다. 전달한 핸들에 따라 타입이 지정되므로(ctx.get(counterCapability)는 값 타입을 반환합니다) getCounter(ctx)와 ctx.get(counterCapability)는 서로 바꿔 사용할 수 있습니다. 훅에서 더 읽기 쉬운 방식을 사용합니다.
capability 이름은 애플리케이션 전체에서 고유해야 합니다. 컴파일 시점 범위 검사는 이름 리터럴을 키로 사용하고(런타임에서는 핸들 참조를 키로 사용), 같은 이름을 공유하는 두 capability는 타입 수준 검사에서 하나로 합쳐집니다.
setup 훅
프로비저닝은 전용 setup(ctx) 훅에서 수행됩니다. 나머지 수명 주기가 시작될 때 모든 capability가 준비되도록 배열 순서의 모든 미들웨어에서 어떤 onConfig(init)보다 먼저 실행됩니다. setup은 변경되지 않는 ChatMiddlewareContext(변경 가능한 구성 아님)를 받으며 비동기일 수 있습니다.
requires / provides / optionalRequires
미들웨어의 세 배열 필드는 capability 계약을 선언합니다. 모두 ReadonlyArray<CapabilityHandle>이며 capability 핸들 자체를 나열합니다.
| 필드 | 의미 |
|---|---|
provides | 이 미들웨어가 설정하는 capability입니다. 각 capability는 setup 내부에서 반드시 provide해야 하며, 그렇지 않으면 설정 단계 후 chat()이 예외를 발생시킵니다. |
requires | 이 미들웨어가 읽는 capability입니다. chat()은 이전 미들웨어가 각 capability를 제공하는지 컴파일 시점과 런타임에 검증합니다. |
optionalRequires | 있는 경우 사용하지만 필수는 아닌 capability입니다. 검증 오류를 발생시키지 않으며 getX(ctx, { optional: true })로 읽습니다. |
배열 예제
defineChatMiddleware로 미들웨어를 작성하면 requires / provides 튜플 타입이 정교해져 범위 검사와 빌더가 이를 정확히 읽을 수 있습니다. 여기서는 제공자가 setup에서 카운터를 설정하고 소비자가 훅에서 이를 읽습니다.
import {
chat,
createCapability,
createChatMiddleware,
defineChatMiddleware,
} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const counterCapability = createCapability<{ value: number }>()("counter");
const [getCounter, provideCounter] = counterCapability;
// Provider: declares `provides` and provisions the value in `setup`.
const withCounter = defineChatMiddleware({
name: "with-counter",
provides: [counterCapability],
setup(ctx) {
provideCounter(ctx, { value: 0 });
},
});
// Consumer: declares `requires` and reads the value via `get` in a hook.
const countsChunks = defineChatMiddleware({
name: "counts-chunks",
requires: [counterCapability],
onChunk(ctx) {
getCounter(ctx).value++;
},
onFinish(ctx) {
console.log(`Saw ${getCounter(ctx).value} chunks`);
},
});
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages: [{ role: "user", content: "Hello" }],
// Provider must come before the consumer.
middleware: [withCounter, countsChunks],
});
배열에서 withCounter를 제거하면 chat()은 누락된 "counter" capability를 명시하는 middleware 옵션에서 컴파일 시점 오류를 보고하며, 런타임에는 어댑터가 호출되기 전에 예외를 발생시킵니다.
빌더 예제
createChatMiddleware()은 연결된 .use() 호출을 통해 배열을 구성하고 컴파일 시 provider-before-consumer 순서를 강제합니다. 각 .use()는 미들웨어의 requires가 이전 .use() 호출에서 제공된 기능으로 이미 충족되어 있어야 합니다.
import {
chat,
createCapability,
createChatMiddleware,
defineChatMiddleware,
} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const counterCapability = createCapability<{ value: number }>()("counter");
const [getCounter, provideCounter] = counterCapability;
const withCounter = defineChatMiddleware({
name: "with-counter",
provides: [counterCapability],
setup(ctx) {
provideCounter(ctx, { value: 0 });
},
});
const countsChunks = defineChatMiddleware({
name: "counts-chunks",
requires: [counterCapability],
onChunk(ctx) {
getCounter(ctx).value++;
},
});
const middleware = createChatMiddleware()
.use(withCounter) // provides "counter"
.use(countsChunks) // requires "counter" — OK, already provided above
.build();
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages: [{ role: "user", content: "Hello" }],
middleware,
});
두 .use() 호출(.use(countsChunks).use(withCounter))의 순서를 바꾸면 제공자보다 소비자가 먼저 배치되므로 빌더가 .use(countsChunks) 줄에서 거부합니다. 따라서 "counter"는 아직 제공된 집합에 없습니다.
검증 보장
capability 시스템은 빠르게 명확한 오류를 발생시킵니다.
- 컴파일 시점 범위. 아무것도 제공하지 않는 필수 capability는
middleware옵션에서 타입 오류로 나타납니다.middleware: [...]의 배열 범위 검사와 순서를 인식하는createChatMiddleware()빌더(순서도 추가로 강제)라는 두 방식으로 적용됩니다. - 런타임 범위. 타입을 우회하더라도 필수 capability가 없으면
chat()이 범위를 검증하고 어댑터 실행 전에 예외를 발생시킵니다. setup후 어설션. 미들웨어가provides에 capability를 선언했지만setup중provide접근자를 호출하지 않으면 설정 단계 후chat()이 예외를 발생시킵니다. 프로비저닝을 조용히 잊을 수 없습니다.- 중복 제공 → 마지막 값 우선 및 경고. 두 미들웨어가 같은 capability를 제공하면 마지막 기록이 우선하고 개발 경고가 출력됩니다.
- 고유 이름. capability
name은 애플리케이션 전체에서 고유해야 하며, 컴파일 시점 범위 검사는 이름 리터럴을 키로 사용합니다(런타임에서는 핸들 참조를 키로 사용).
기본 제공 미들웨어
TanStack AI는 도구 결과 캐싱, 스트리밍 텍스트 가리기 및 OpenTelemetry 추적과 같은 일반적인 용도의 미리 준비된 미들웨어를 제공합니다.
| 미들웨어 | 가져오기 | 동작 |
|---|---|---|
toolCacheMiddleware | @tanstack/ai/middlewares | 이름과 인수별 도구 호출 결과를 캐시합니다 |
contentGuardMiddleware | @tanstack/ai/middlewares | 스트리밍 텍스트 콘텐츠를 가리거나 변환하거나 차단합니다 |
otelMiddleware | @tanstack/ai/middlewares/otel | OpenTelemetry 스팬과 GenAI 메트릭을 내보냅니다 |
각 미들웨어의 전체 옵션과 예제는 기본 제공 미들웨어를 참조하세요. 아래 레시피에서는 직접 만드는 방법을 보여줍니다.
레시피
실시간 사용자 지정 이벤트
긴 onConfig 훅이 대기하는 동안 클라이언트에 진행 상황을 보낼 수 있습니다. 작업이 시작될 때 ctx.emitCustomEvent를 호출하고 완료될 때 다시 호출합니다.
import { type ChatMiddleware } from "@tanstack/ai";
async function prepare() {
await new Promise<void>((resolve) => {
setTimeout(resolve, 1);
});
}
const progress: ChatMiddleware = {
name: "progress",
async onConfig(ctx) {
if (ctx.phase !== "beforeModel") return;
ctx.emitCustomEvent("job:started", { step: "prepare" });
await prepare();
ctx.emitCustomEvent("job:ended", { step: "prepare" });
},
};
엔진은 emitCustomEvent를 호출하는 즉시 각 CUSTOM 청크를 생성합니다. 아직 RUN_STARTED가 전송되지 않았다면 엔진이 먼저 보냅니다. 도구의 emitCustomEvent 호출과 같은 방식으로 클라이언트에서 이 이벤트를 읽습니다. 사용자 지정 이벤트를 참조하세요.
속도 제한
요청당 도구 호출 수를 제한합니다.
import { type ChatMiddleware } from "@tanstack/ai";
function rateLimitMiddleware(maxCalls: number): ChatMiddleware {
let toolCallCount = 0;
return {
name: "rate-limit",
onBeforeToolCall: (ctx, hookCtx) => {
toolCallCount++;
if (toolCallCount > maxCalls) {
return {
type: "abort",
reason: `Rate limit: exceeded ${maxCalls} tool calls`,
};
}
},
};
}
감사 추적
규정 준수를 위해 모든 작업을 기록합니다.
import { type ChatMiddleware } from "@tanstack/ai";
import { db } from "./db";
const auditTrail: ChatMiddleware = {
name: "audit-trail",
onStart: (ctx) => {
ctx.defer(
db.auditLog.create({
requestId: ctx.requestId,
event: "chat_started",
timestamp: Date.now(),
})
);
},
onAfterToolCall: (ctx, info) => {
ctx.defer(
db.auditLog.create({
requestId: ctx.requestId,
event: "tool_executed",
toolName: info.toolName,
success: info.ok,
duration: info.duration,
timestamp: Date.now(),
})
);
},
onFinish: (ctx, info) => {
ctx.defer(
db.auditLog.create({
requestId: ctx.requestId,
event: "chat_finished",
duration: info.duration,
tokens: info.usage?.totalTokens,
timestamp: Date.now(),
})
);
},
};
반복별 도구 교체
에이전트 루프의 단계별로 다른 도구를 노출합니다.
import { type ChatMiddleware } from "@tanstack/ai";
const toolSwapper: ChatMiddleware = {
name: "tool-swapper",
onConfig: (ctx, config) => {
if (ctx.phase !== "beforeModel") return;
if (ctx.iteration === 0) {
// First iteration: only allow search
return {
tools: config.tools.filter((t) => t.name === "search"),
};
}
// Later iterations: allow all tools
},
};
콘텐츠 필터링
청크가 소비자에게 도달하기 전에 삭제하거나 변환합니다.
import { type ChatMiddleware } from "@tanstack/ai";
import { containsProfanity } from "./filters";
const contentFilter: ChatMiddleware = {
name: "content-filter",
onChunk: (ctx, chunk) => {
if (chunk.type === "TEXT_MESSAGE_CONTENT") {
if (containsProfanity(chunk.delta)) {
// Drop the chunk entirely
return null;
}
}
},
};
재시도 로깅을 포함한 오류 복구
import { type ChatMiddleware } from "@tanstack/ai";
import { alertService } from "./services";
const errorRecovery: ChatMiddleware = {
name: "error-recovery",
onError: (ctx, info) => {
ctx.defer(
alertService.send({
level: "error",
message: `Chat ${ctx.requestId} failed after ${info.duration}ms`,
error: String(info.error),
})
);
},
};
TypeScript 타입
핵심 미들웨어 타입은 @tanstack/ai에서 내보냅니다.
import type {
ChatMiddleware,
ChatMiddlewareContext,
ChatMiddlewarePhase,
ChatMiddlewareConfig,
StructuredOutputMiddlewareConfig,
ToolCallHookContext,
BeforeToolCallDecision,
AfterToolCallInfo,
IterationInfo,
ToolPhaseCompleteInfo,
UsageInfo,
FinishInfo,
AbortInfo,
ErrorInfo,
} from "@tanstack/ai";
기본 제공 미들웨어의 옵션 및 타입은 기본 배럴이 아닌 @tanstack/ai/middlewares 서브 경로에서 내보냅니다.
import type {
ToolCacheMiddlewareOptions,
ToolCacheStorage,
ToolCacheEntry,
ContentGuardMiddlewareOptions,
ContentGuardRule,
ContentFilteredInfo,
} from "@tanstack/ai/middlewares";
다음 단계
- Built-in 미들웨어 —
toolCacheMiddleware,contentGuardMiddleware,otelMiddleware - 압축:
withCompaction으로 긴 대화를 컨텍스트 제한 안에서 유지합니다 - OpenTelemetry —
otelMiddleware를 통해 트레이스와 메트릭을 내보냅니다 - 도구 — 동형 도구 시스템을 알아봅니다
- 에이전트 주기 — 다단계 에이전트 루프를 이해합니다
- 스트리밍 — TanStack AI에서 스트리밍이 작동하는 방식을 알아봅니다