Skip to content
LLM 应用架构设计:从模型调用到完整 AI 应用运行时
概述
LLM 应用的工程实现,围绕一个基础交互展开:把消息数组发送给模型 API,拿到模型返回的文本。模型本身不保存调用历史,不保证输出格式,也不直接访问外部系统。因此,一个完整的 LLM 应用需要在模型 API 之上补充上下文管理、状态存储、工具调用、安全控制和可观测性等能力。这些能力组合起来,就是一个可运行的 LLM 应用运行时。
以下内容从模型调用层开始,逐步向上构建各层组件。示例代码使用 TypeScript,运行环境为 Node.js。
分层与基本概念
LLM 应用可以按职责划分为几个层次:
- 接入层:处理客户端请求、用户身份认证、参数校验、基础限流。
- 编排层:管理会话状态、决定是否调用工具、驱动多步任务。
- 模型调用层:统一模型 API 的调用方式,处理流式输出、重试与错误。
- 数据层:保存会话消息、外部知识索引、缓存与记忆。
各层之间通过明确的接口交互。业务逻辑不需要直接接触模型 API 细节,数据层也不关心上层使用的是哪家模型。
构建这些层次之前,需要先熟悉 LLM 应用开发中的基本概念。
- Token:模型处理文本的基本单位。一段文本会先被切分为 token 序列。模型计费、上下文长度、速率限制都以 token 为单位。
- 上下文窗口:模型单次请求能够处理的最大 token 数,包括系统指令、历史消息、工具定义和本次回复。超出上限时请求会失败。
- Messages:Chat Completion API 的输入格式。它是有序的消息数组,每条消息包含
role和content。常见角色包括system(系统指令)、user(用户输入)、assistant(模型回复)和tool(工具执行结果)。 - 采样参数:控制生成随机性的参数,典型例子是
temperature。较低的值让输出更稳定,较高的值增加多样性。 - Completion 与 ChatCompletion:Completion API 是早期以纯文本为输入的接口。ChatCompletion 使用消息数组,是当前主流模型普遍采用的接口形式。
- Embedding:文本向量化接口。输入文本,输出浮点数数组。向量之间的相似度可用于语义检索。
- Function Calling:模型根据应用声明的工具定义,输出结构化的调用参数。应用执行工具后把结果回传给模型继续生成。
这些概念会贯穿后续各章。下面从模型调用层开始,用代码逐步实现一个运行时。
模型调用层:Chat Completion API 的封装
模型调用层最基本的职责是封装 Chat Completion API。一个最小封装需要处理三件事:请求参数、错误处理、返回结构。
先定义消息类型:
ts
type ChatMessage = {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string;
tool_call_id?: string;
};tool_call_id 只在角色为 tool 时使用,用于关联某次工具调用的结果。
再定义工具声明与模型返回结构:
ts
type ToolDefinition = {
type: 'function';
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
};
type ToolCall = {
id: string;
type: 'function';
function: {
name: string;
arguments: string;
};
};
type ChatResponse = {
choices: Array<{
message: ChatMessage & {
tool_calls?: ToolCall[];
};
}>;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
};ChatResponse 中的 usage 用于统计 token 消耗,也是成本核算和限流的重要依据。
一个基本的非流式调用封装如下。这里假设 modelUrl 和 authHeaders 由外层配置模块提供:
ts
type ChatCompletionConfig = {
url: string;
headers: Record<string, string>;
model: string;
messages: ChatMessage[];
temperature?: number;
tools?: ToolDefinition[];
response_format?: Record<string, unknown>;
};
async function chatCompletion(
config: ChatCompletionConfig,
): Promise<ChatResponse> {
const body: Record<string, unknown> = {
model: config.model,
messages: config.messages,
};
if (config.temperature !== undefined) {
body.temperature = config.temperature;
}
if (config.tools !== undefined) {
body.tools = config.tools;
}
if (config.response_format !== undefined) {
body.response_format = config.response_format;
}
const response = await fetch(config.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...config.headers,
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(
`模型接口返回 ${response.status}: ${await response.text()}`,
);
}
return response.json() as Promise<ChatResponse>;
}这个函数不持有任何服务商特有的逻辑。模型服务的 URL、认证方式和模型名称都由调用方传入。不同服务商的认证方式并不统一,常见做法是在 headers 中携带 API Key。把认证细节放在调用方,可以让同一套封装适配不同的服务商。
流式输出是模型调用的另一个关键能力。模型生成完整回答需要数秒到数十秒,非流式接口会让客户端长时间等待。SSE(Server-Sent Events)是流式输出常用的传输格式。封装时可以使用异步生成器:
ts
type StreamChunk = {
choices: Array<{
delta: {
content?: string;
};
}>;
};
async function* streamChatCompletion(
stream: AsyncIterable<StreamChunk>,
): AsyncGenerator<string> {
for await (const chunk of stream) {
const content = chunk.choices?.[0]?.delta?.content;
if (content) {
yield content;
}
}
}这个例子的输入是一个 AsyncIterable<StreamChunk>,可以由底层 HTTP 客户端从 SSE 数据流解析而来。业务层拿到的是一个逐段输出的字符串序列,不需要关心网络传输细节。
流式输出依赖异步迭代器。若使用 TypeScript,需要将编译目标设置为 ES2018 或更高。
结构化输出
普通文本输出无法保证 JSON 格式合法。部分服务商提供结构化输出能力,在请求中声明期望的 JSON Schema,模型返回严格匹配该 Schema 的内容。示例:
ts
const result = await chatCompletion({
url: modelUrl,
headers: authHeaders,
model,
messages: [
{
role: 'user',
content: '从这段文本中提取日期和金额。',
},
],
response_format: {
type: 'json_schema',
json_schema: {
name: 'expense_record',
schema: {
type: 'object',
properties: {
date: { type: 'string' },
amount: { type: 'number' },
},
required: ['date', 'amount'],
},
},
},
});上面的 response_format 是服务商提供的请求参数,具体的字段名和严格程度因服务商而异。模型访问层通常需要做一层适配来隐藏这些差异。[2]
结构化输出适合实体抽取、信息提取等需要稳定字段的场景。结构化输出也可以与 Zod 这类校验库配合。先用 Zod 定义 Schema,再生成对应的 JSON Schema 定义,避免在代码中重复维护两套结构。[4]
response_format 与工具调用的关系
除了 response_format,工具调用是另一种获得结构化输出的方式。工具调用的参数由 JSON Schema 定义,模型返回的 tool_calls[].function.arguments 是符合该 Schema 的 JSON 字符串。不同服务商在这套机制上的行为略有差异。例如,xAI 在工具调用中隐式强制严格模式;OpenAI 则通过 response_format 显式控制。[2]
多模型适配层需要处理这些差异。一个可行的思路是:应用面向自己的接口编程,各服务商适配器负责转换参数格式。
上下文工程:Prompt 模板与上下文窗口管理
LLM 的输入是消息数组,而不是普通字符串。上下文工程的首要任务是维护这个数组。
Prompt 模板
使用函数生成消息数组,而不是直接拼接字符串,可以让结构更清晰:
ts
function buildMessages(question: string, knowledge: string[]): ChatMessage[] {
const context = knowledge.join('\n');
return [
{
role: 'system',
content: '你是文档助手。只基于提供的资料回答,不要编造。',
},
{
role: 'user',
content: `资料:\n${context}\n\n问题:${question}`,
},
];
}系统消息用于设定行为边界,用户消息携带具体请求。两者在消息数组中的位置是固定的。若检索到的知识为空,模板仍然会生成一个“资料:”占位,实际使用中需要根据知识数量决定是否加入上下文。
上下文窗口裁剪
上下文窗口有限。随着对话进行,消息数组会不断增长,最终超过模型单次请求的上限。一个基础策略是裁剪:保留系统消息,丢弃最早的非系统消息。
ts
const MAX_MESSAGES = 10;
function trimMessages(messages: ChatMessage[]): ChatMessage[] {
if (messages.length <= MAX_MESSAGES) {
return messages;
}
const system = messages.filter((m) => m.role === 'system');
const rest = messages.filter((m) => m.role !== 'system');
const restCount = Math.max(0, MAX_MESSAGES - system.length);
return [...system, ...rest.slice(-restCount)];
}这里 MAX_MESSAGES 是对消息条数的限制,不是对 token 数量的限制。更精确的做法是在写入消息数组时累计 token 数,超过阈值时裁剪。Token 计数需要引入 tokenizer,不同模型的 tokenizer 并不相同。如果不想引入额外依赖,可以先用字符长度估算,再根据模型返回的 usage 字段校准。
历史消息压缩
裁剪会直接丢弃信息。对于需要保留早期信息的长对话,可以在裁剪前把早期消息交给模型做摘要,将摘要作为新的系统消息内容。这样既保留关键信息,又控制上下文长度。
ts
async function compressMessages(
messages: ChatMessage[],
): Promise<ChatMessage> {
const conversation = messages
.map((m) => `${m.role}: ${m.content}`)
.join('\n');
const response = await chatCompletion({
url: modelUrl,
headers: authHeaders,
model,
messages: [
{
role: 'system',
content: '请将下面的对话压缩为 200 字以内的摘要,保留关键事实。',
},
{
role: 'user',
content: conversation,
},
],
});
return {
role: 'system',
content: `对话摘要:${response.choices[0].message.content}`,
};
}调用方可以把这条摘要放在消息数组开头,替代被压缩的消息。注意,摘要本身也会消耗 token,而且摘要过程可能丢失细节。是否需要压缩,取决于对话长度和业务对完整度的要求。
工具调用:Function Calling 与 Agent 循环
工具调用是模型与外部系统交互的机制。应用在请求中通过 tools 参数声明可用函数,模型在回答中返回 tool_calls,应用执行函数后,把结果以 tool 消息回传,模型再继续生成。[1]
一个天气查询工具的定义如下:
ts
const tools: ToolDefinition[] = [
{
type: 'function',
function: {
name: 'get_weather',
description: '获取指定城市的当前天气',
parameters: {
type: 'object',
properties: {
city: { type: 'string', description: '城市名' },
},
required: ['city'],
},
},
},
];工具声明中的 name、description 和 parameters 会影响模型何时选择该工具以及生成什么参数。描述应当准确,参数 Schema 应当严格。
工具的实际执行器注册在一个 Map 中:
ts
const toolRegistry = new Map<string, (args: any) => unknown>();
function registerTool(name: string, fn: (args: any) => unknown) {
toolRegistry.set(name, fn);
}
function executeTool(name: string, argsJson: string): unknown {
const fn = toolRegistry.get(name);
if (!fn) {
throw new Error(`未知工具: ${name}`);
}
return fn(JSON.parse(argsJson));
}
registerTool('get_weather', (args: { city: string }) => {
// 实际项目中在这里调用天气服务
return { city: args.city, temperature: 20, unit: 'celsius' };
});Agent 循环是工具调用的核心流程。它由几个步骤组成:
- 把所有消息(包含工具定义)发给模型。
- 检查模型返回中是否包含
tool_calls。 - 如果没有,模型回答即为最终结果,循环结束。
- 如果有,逐一执行工具,把结果追加为
tool消息,回到第 1 步。
ts
async function runAgent(userMessage: string, maxSteps = 5): Promise<string> {
const messages: ChatMessage[] = [
{ role: 'system', content: '你是一个可以调用工具的助手。' },
{ role: 'user', content: userMessage },
];
for (let step = 0; step < maxSteps; step++) {
const response = await chatCompletion({
url: modelUrl,
headers: authHeaders,
model,
messages,
tools,
});
const assistantMessage = response.choices[0].message;
messages.push(assistantMessage);
if (!assistantMessage.tool_calls) {
return assistantMessage.content;
}
for (const call of assistantMessage.tool_calls) {
try {
const output = executeTool(call.function.name, call.function.arguments);
messages.push({
role: 'tool',
tool_call_id: call.id,
content: JSON.stringify(output),
});
} catch (error) {
messages.push({
role: 'tool',
tool_call_id: call.id,
content: `调用失败: ${String(error)}`,
});
}
}
}
throw new Error(`Agent 超过最大步数 ${maxSteps}`);
}模型返回的 assistantMessage 需要重新推入 messages。如果带有 tool_calls,模型接下来会看到工具执行结果并继续生成;如果没有 tool_calls,则说明模型已经完成回答。
参数解析是一个易错点。call.function.arguments 是 JSON 字符串,需要先解析再执行。解析失败或执行失败时,同样要把错误信息作为 tool 消息回传给模型,让模型调整参数后重试。
Agent 循环必须设置最大步数。工具调用链可能很长,也可能在模型错误的引导下循环往复。步数上限是防止无限循环的基本保障。
知识增强:RAG 架构设计
模型的知识截止于训练数据。RAG(检索增强生成)通过把外部知识库中的相关内容检索出来,拼入 Prompt,让模型基于这些内容回答。
Embedding 是 RAG 的基础。Embedding 接口输入文本,输出浮点数向量。语义相近的文本,向量距离也更近。
RAG 的完整流程:
- 将文档切分为片段。
- 对每个片段调用 Embedding API,得到向量。
- 将向量和原文存入向量数据库。
- 查询时,对用户问题做 Embedding。
- 在向量数据库中检索最相似的片段。
- 将片段拼入消息数组,交给模型生成回答。
不使用向量数据库时,可以在内存中做暴力检索。下面的示例展示了向量检索的核心逻辑:
ts
type Vector = number[];
function cosineSimilarity(a: Vector, b: Vector): number {
if (a.length !== b.length) return 0;
const dot = a.reduce((sum, value, index) => sum + value * b[index], 0);
const normA = Math.sqrt(a.reduce((sum, value) => sum + value ** 2, 0));
const normB = Math.sqrt(b.reduce((sum, value) => sum + value ** 2, 0));
if (normA === 0 || normB === 0) return 0;
return dot / (normA * normB);
}
function retrieve(
question: string,
documents: Array<{ text: string; vector: Vector }>,
topK = 2,
) {
const questionVector = embed(question);
return documents
.map((doc) => ({
text: doc.text,
score: cosineSimilarity(questionVector, doc.vector),
}))
.sort((a, b) => b.score - a.score)
.slice(0, topK)
.map((item) => item.text);
}例子中的 embed 函数负责调用服务商提供的 Embedding API。实际项目中,文档向量存入向量数据库,当文档数量很大时,暴力检索的线性扫描代价过高,需要依赖向量索引。
文档切分的大小会影响检索效果。切分过小,单个片段信息不足;切分过大,片段内噪声增加,且容易超出上下文窗口。常见的做法是按段落或固定字符长度切分,并保留相邻片段之间的少量重叠。
状态与记忆:会话持久化与外部记忆
Chat Completion API 本身没有状态。同一个用户的两条消息之间没有任何关联,应用需要自己保存历史消息。
最简单的状态存储方式是按会话 ID 保存完整消息数组。下面的示例使用 Redis,省略了连接初始化步骤:
ts
import { createClient } from 'redis';
const redis = createClient();
async function saveMessages(sessionId: string, messages: ChatMessage[]) {
await redis.set(`session:${sessionId}`, JSON.stringify(messages));
}
async function loadMessages(sessionId: string): Promise<ChatMessage[]> {
const raw = await redis.get(`session:${sessionId}`);
return raw ? (JSON.parse(raw) as ChatMessage[]) : [];
}每次用户请求到来时,应用加载历史消息,追加用户消息,发送给模型,再把模型回复追加回去并保存。
ts
async function handleMessage(sessionId: string, userContent: string) {
const history = await loadMessages(sessionId);
const messages = [
...history,
{ role: 'user' as const, content: userContent },
];
const response = await chatCompletion({
url: modelUrl,
headers: authHeaders,
model,
messages,
});
const assistantMessage = response.choices[0].message;
await saveMessages(sessionId, [...messages, assistantMessage]);
return assistantMessage.content;
}会话消息可以设置过期时间。例如,Redis 的 EX 参数可以在写入时设置 TTL,让长时间不活跃的会话自动清理。
外部记忆是比会话历史更进一步的机制。它把跨会话的重要信息存入向量库。当用户发起新对话时,应用先从外部记忆中检索与当前问题相关的事实,拼入系统消息。这就把长期记忆和 RAG 统一到了同一套架构中。
缓存、限流与容错设计
缓存
LLM API 的延迟和成本都显著高于普通 HTTP API。缓存相同请求的响应,可以减少重复调用。
缓存键可以由消息数组和模型名共同决定:
ts
import { createHash } from 'node:crypto';
function cacheKey(messages: ChatMessage[], model: string): string {
const body = JSON.stringify({ model, messages });
return createHash('sha256').update(body).digest('hex');
}
async function getCachedOrFetch(params: ChatCompletionConfig) {
const key = cacheKey(params.messages, params.model);
const cached = await redis.get(`llm-cache:${key}`);
if (cached) {
return JSON.parse(cached) as ChatResponse;
}
const response = await chatCompletion(params);
await redis.set(`llm-cache:${key}`, JSON.stringify(response), {
EX: 3600,
});
return response;
}缓存命中会直接返回相同的输出。对于闲聊、创意写作等要求多样性的场景,缓存并不合适。缓存更适合信息提取、分类、翻译等确定性任务。
限流
模型服务商对每分钟请求数和 token 数有限制。超过限制会返回 429 状态码。应用层限流可以在请求发出前拦截一部分超额流量。
令牌桶是一种常见的限流算法:
ts
class TokenBucket {
private tokens: number;
private lastRefill = Date.now();
constructor(
private capacity: number,
private refillPerSecond: number,
) {
this.tokens = capacity;
}
take(): boolean {
const now = Date.now();
const elapsedSeconds = (now - this.lastRefill) / 1000;
this.tokens = Math.min(
this.capacity,
this.tokens + elapsedSeconds * this.refillPerSecond,
);
this.lastRefill = now;
if (this.tokens < 1) {
return false;
}
this.tokens -= 1;
return true;
}
}令牌桶按固定速率补充令牌。突发请求会消耗剩余令牌,令牌耗尽后请求被拒绝。实际使用中,拒绝策略可以是返回 429,也可以是把请求排入队列。
容错
请求模型服务的失败率通常高于普通内网接口。网络错误和瞬时过载并不少见。重试是容错的基础手段:
ts
async function withRetry<T>(
fn: () => Promise<T>,
retries = 3,
): Promise<T> {
for (let attempt = 0; attempt < retries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === retries - 1) {
throw error;
}
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 200));
}
}
throw new Error('unreachable');
}重试间隔采用指数退避。这里从 200 毫秒开始,每次翻倍。
注意,重试并不总是安全。若模型已经生成了一部分响应(流式场景),重试会导致客户端收到重复内容。可以安全重试的情况通常是连接尚未返回任何响应体时的错误,或明确的 429、5xx 状态码。若流式响应已经开始返回,应中断当前连接并直接向客户端返回错误,而不是自动重试。
安全与隐私:认证授权与 Prompt 注入防护
认证授权
LLM 应用涉及两层认证:
- 用户身份认证:由应用层处理,决定用户是否有权限访问某个会话或功能。
- 模型 API 认证:由服务端保存 API Key,应用服务作为一个后端客户端调用模型服务。
用户身份不应该直接透传给模型 API。若模型 API Key 暴露在浏览器端,任何人都可以绕过应用直接调用模型服务,造成成本失控。
Prompt 注入防护
Prompt 注入是 LLM 应用特有的安全风险。用户输入中可能包含试图覆盖系统指令的文本,例如“忽略之前的指令”。由于模型无法可靠地区分指令和数据,防护需要多管齐下。
第一种手段是消息角色隔离。将系统指令放在 system 消息中,用户输入放在 user 消息中。这是 API 层的基础隔离,但它不是完整防护。
第二种手段是输入过滤。在用户输入进入消息数组之前,检测并移除已知的注入模式:
ts
function sanitizeInput(input: string): string {
return input
.replace(/忽略(之前|以上)指令/gi, '[忽略]')
.replace(/ignore (previous|above) instructions/gi, '[ignored]');
}这种模式匹配非常有限,不能覆盖未知的注入方式。
第三种手段是输出过滤。模型输出可能包含不应当展示的内容。在返回给客户端之前,对输出做一次检查:
ts
function filterOutput(text: string): string {
const sensitivePattern = /sk-[a-zA-Z0-9]{16,}/;
return text.replace(sensitivePattern, '[敏感信息已过滤]');
}输出过滤同样只能覆盖已知模式。对于敏感信息,更可靠的做法是确保这类数据根本不进入模型输入。
日志安全
模型请求和响应中可能包含用户隐私数据。日志系统不应记录完整的 Prompt 和模型输出。如果确有必要,需要对敏感字段做脱敏处理。
可观测性:日志、追踪与输出评估
可观测性解决三个问题:调用是否成功、耗时多少、质量如何。
每个模型调用节点都可以记录基础指标:
ts
async function withModelLogging<T>(
name: string,
fn: () => Promise<T>,
): Promise<T> {
const start = Date.now();
try {
const result = await fn();
console.log(
JSON.stringify({
event: 'model_call',
name,
durationMs: Date.now() - start,
ok: true,
}),
);
return result;
} catch (error) {
console.error(
JSON.stringify({
event: 'model_call',
name,
durationMs: Date.now() - start,
ok: false,
error: String(error),
}),
);
throw error;
}
}记录内容至少包含:
- 模型名称
- 请求耗时
- 是否成功(HTTP 状态码或错误类型)
- token 用量(可以从
usage字段取得)
当请求经过多个服务时,需要为整个链路生成一个 traceId,并在日志中传递。这样可以把接入层日志、模型调用日志、工具执行日志关联起来。
流式响应的耗时指标略有不同。除了总耗时,还应该记录首 token 延迟。首 token 延迟是用户感觉到模型开始响应的时间。
输出评估是对模型输出质量的量化。方式包括:
- 规则检查:输出是否为合法 JSON,是否包含必需字段。
- 关键词匹配:是否包含期望的事实。
- 语义相似度:与期望答案向量之间的余弦相似度。
- 模型评判:用另一个模型对输出打分。
规则检查可以直接在测试和日志管道中执行:
ts
function validateJsonOutput(text: string): boolean {
try {
const data = JSON.parse(text);
return data && typeof data === 'object';
} catch {
return false;
}
}输出评估不应只关注单次请求。更合理的做法是定期在测试数据集上运行评估,记录分数随版本的变化。
测试策略:单元测试与 LLM 输出评测
单元测试
单元测试的目标是验证应用逻辑,不依赖真实模型服务。做法是把模型客户端替换为 mock 对象。
ts
function createMockClient(fixtures: ChatResponse[]) {
let index = 0;
return {
async chatCompletion(_config: ChatCompletionConfig) {
if (index >= fixtures.length) {
throw new Error('fixtures 已用完');
}
return fixtures[index++];
},
};
}测试 Agent 循环时,准备两个 fixture:第一个返回带 tool_calls 的响应,第二个返回最终回答。
ts
const responses: ChatResponse[] = [
{
choices: [
{
message: {
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call_1',
type: 'function',
function: {
name: 'get_weather',
arguments: JSON.stringify({ city: '北京' }),
},
},
],
},
},
],
},
{
choices: [
{
message: {
role: 'assistant',
content: '北京当前 20 度。',
},
},
],
},
];
const client = createMockClient(responses);
const result = await runAgentWithClient(client, '北京天气怎么样?');
expect(result).toBe('北京当前 20 度。');runAgentWithClient 是 runAgent 的变体,区别在于接受注入的模型客户端。测试目标函数需要支持依赖注入,才能在不发起真实网络请求的情况下验证工具调用流程。
集成测试
集成测试使用真实模型服务,但用例数量有限,主要用于验证适配器是否正确。集成测试应在独立的测试环境中运行,设置调用次数上限,避免成本超出预期。
LLM 输出评测
模型输出具有不确定性。评测断言应使用宽松条件:
ts
const evalCases = [
{
input: '巴黎是哪个国家的首都?',
expectedContains: ['法国'],
},
{
input: '2 + 2 等于几?',
expectedContains: ['4'],
},
];
for (const testCase of evalCases) {
const output = await runAgent(testCase.input);
const passed = testCase.expectedContains.some((keyword) =>
output.includes(keyword),
);
console.log(`${testCase.input} => ${passed ? 'PASS' : 'FAIL'}`);
}精确字符串匹配会导致大量误报。更好的方式是综合使用关键词、JSON Schema 校验和语义相似度。语义相似度需要 Embedding API,测试成本会上升,但能覆盖更多变体表达。
部署与发布:容器化、Serverless 与 CI/CD
容器化
模型调用层、编排层和数据层都是无状态的服务,可以容器化部署。一个最小 Dockerfile 如下:
dockerfile
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "dist/index.js"]多阶段构建可以把编译阶段和运行阶段分开:
dockerfile
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/index.js"]运行时镜像只包含编译产物和运行依赖,不包含源码和开发工具。
Serverless 部署
Serverless 平台按请求计费,适合调用量波动的场景。但流式输出在 Serverless 环境中需要注意限制:平台可能在响应结束前强制断开连接,或对单次请求时长设置上限。如果应用需要长时间 SSE 流式响应,需要确认平台是否支持。
无论使用哪种部署方式,模型 API Key 都应通过环境变量或密钥管理服务注入,不写入镜像和代码仓库。
CI/CD
流水线通常包含以下阶段:
- 类型检查与 lint。
- 单元测试。
- LLM 输出评测(在测试数据集上运行)。
- 构建镜像。
- 部署到目标环境。
LLM 输出评测作为门禁时,需要维护一个固定的评测数据集,并记录历史分数。模型行为会随服务商更新而变化,评测分数下降时应能定位到对应的版本变更。
框架与生态:LangChain、LlamaIndex 与 AI Gateway
LangChain 和 LlamaIndex 是两类常见的 LLM 应用框架。
LangChain 的定位偏重于应用编排。它提供模型封装、提示模板、工具注册、记忆组件和 Agent 运行时。开发者可以在较短时间内搭建出一个具备工具调用能力的应用。
LlamaIndex 的定位偏重于数据连接与索引。它在文档加载、切分、向量化和检索方面提供较完整的抽象,适合以 RAG 为核心的应用。
框架抽象了模型调用细节,但并不能消除工程化的其余部分。应用自身的状态管理、可观测性、安全控制和评测体系,仍然需要按照业务需求构建。框架选型不应替代架构设计,而是作为架构中的一部分。
模型服务商之间的 API 差异是工程中实际存在的问题。以 Anthropic 和 OpenAI 兼容接口为例:Anthropic 原生端点为 /v1/messages,需要 anthropic-version 头;OpenAI 兼容端点为 /v1/chat/completions。若直接使用 OpenAI 兼容 SDK 对接 Anthropic 原生端点,将会返回 404。多模型适配层应封装这些差异,避免业务代码与单一厂商绑定。[3]
AI Gateway 是位于应用和模型服务之间的代理层。它统一处理认证、路由、限流、重试和格式转换。一个最小适配接口可以这样定义:
ts
interface ChatProvider {
chat(params: ChatCompletionConfig): Promise<ChatResponse>;
stream(params: ChatCompletionConfig): AsyncIterable<StreamChunk>;
}每种服务商实现一个 ChatProvider。应用只依赖这个接口。切换模型服务商时,修改的是 Provider 的适配器,而不是业务代码。
综合示例:从 Chat API 到完整 LLM 应用运行时
把前面各章的能力组合起来,可以构造一个简化的运行时。它具备会话存储、工具调用循环、消息裁剪和日志记录。tools 来自“工具调用”一节,trimMessages 来自“上下文窗口管理”,executeTool 来自“工具执行器注册”。
ts
type KVStore = {
get(sessionId: string): Promise<ChatMessage[] | null>;
set(sessionId: string, messages: ChatMessage[]): Promise<void>;
};
type Logger = (entry: Record<string, unknown>) => void;
class LLMRuntime {
constructor(
private provider: ChatProvider,
private kv: KVStore,
private model: string,
private logger: Logger,
) {}
async chat(sessionId: string, userMessage: string): Promise<string> {
const history = (await this.kv.get(sessionId)) ?? [];
const messages: ChatMessage[] = [
...history,
{ role: 'user', content: userMessage },
];
for (let step = 0; step < 5; step++) {
const start = Date.now();
const response = await this.provider.chat({
model: this.model,
messages,
tools,
});
this.logger({
event: 'model_call',
sessionId,
toolCalls: response.choices[0].message.tool_calls?.length ?? 0,
durationMs: Date.now() - start,
});
const assistantMessage = response.choices[0].message;
messages.push(assistantMessage);
if (!assistantMessage.tool_calls) {
const trimmed = trimMessages(messages);
await this.kv.set(sessionId, trimmed);
return assistantMessage.content;
}
for (const call of assistantMessage.tool_calls) {
const args = JSON.parse(call.function.arguments);
try {
const output = executeTool(call.function.name, args);
messages.push({
role: 'tool',
tool_call_id: call.id,
content: JSON.stringify(output),
});
} catch (error) {
messages.push({
role: 'tool',
tool_call_id: call.id,
content: `调用失败: ${String(error)}`,
});
}
}
}
throw new Error('Agent 超过最大步数');
}
}ChatProvider 的具体实现可以是直接调用某个模型服务商的 HTTP API,也可以是经过 AI Gateway 的代理客户端。KVStore 的具体实现可以是 Redis,也可以是内存 Map。依赖注入让这两部分可以在测试环境替换。
初始化示例:
ts
const runtime = new LLMRuntime(
openAICompatibleProvider,
redisKVStore,
'your-model-name',
(entry) => console.log(JSON.stringify(entry)),
);这个运行时虽然简化,但已经具备完整流程:从存储中恢复会话,携带工具定义调用模型,执行工具并回传结果,裁剪超长消息,记录每次模型调用日志。在此基础上扩展限流、缓存、评测和部署,就是一套完整的 LLM 应用后端。
