Skip to content
企业级 LLM 应用架构:模型接入、服务编排与应用运行时
概述
企业级 LLM 应用通常需要接入多家模型供应商,并在模型能力之上叠加工具调用、多轮会话、状态管理、安全策略等逻辑。如果业务代码直接依赖某个供应商的 SDK,切换模型或增加供应商都需要改动大量上层代码。
整个架构按三个层次展开:模型接入层负责屏蔽供应商差异,服务编排层负责组织模型能力,应用运行时负责会话、流式传输和部署形态。示例代码以 TypeScript 为主,使用 ES6+ 的类、接口、async/await、异步迭代器和 Map 等特性。
基本概念
LLM 应用与传统后端应用有几个关键差异。
第一,接口输入输出是自然语言。模型 API 不接受结构化业务对象,只接受消息列表,返回文本或工具调用指令。
第二,模型本身无状态。每一次请求都是独立计算,多轮对话的上下文需要由应用层保存并回传。
第三,输出具有随机性。温度等参数影响生成分布,不能假定同一个请求必然返回相同结果。
第四,上下文窗口是硬限制。模型一次调用能处理的 token 总量有上限,超出后需要裁剪、压缩或使用其他策略。
Token 是模型处理文本的基本单位,可以是单词、子词或单个字符。上下文窗口指输入 token 与输出 token 的总和限制。
消息格式通常包含以下角色:
system:定义助手行为。user:用户输入。assistant:模型回复。tool:工具调用返回结果。
不同供应商的角色映射不同。OpenAI 的后续格式是 assistant 与 tool;Gemini 使用 model 与 tool;Anthropic 只有 user 与 assistant,工具调用通过消息内容块 tool_use 与 tool_result 表达[1]。
函数调用(Function Calling)是模型输出结构化指令的能力。模型在回复中携带工具名称和参数,应用执行实际函数,再把结果作为新消息传回模型。这个循环是 Agent 编排的基础。
流式输出允许模型逐步生成 token,客户端无需等待完整响应,通常通过 SSE 实现。
模型 API 分为托管服务和自托管推理。OpenAI、Anthropic、Google 提供托管 API;llama.cpp、vLLM 等自托管推理项目暴露 OpenAI 兼容端点[3]。Ollama 默认使用自有 /api/chat 格式,但也提供 OpenAI 兼容端点[3]。
三层架构总览
整体结构可表示为:
text
客户端
↓
应用运行时:会话与状态、上下文窗口、异步流式、部署形态
↓
服务编排层:Prompt 管理、工具调用、Agent 循环、安全策略
↓
模型接入层:适配器、重试/熔断/限流、语义缓存
↓
模型供应商 API / 自托管推理应用运行时负责对外暴露 HTTP 或 gRPC 接口,维护会话状态,并将请求转交给服务编排层。服务编排层决定使用哪个 Prompt、是否调用工具、如何循环。模型接入层负责实际调用模型 API,并处理重试、熔断、限流和缓存。
分层后,每一层都可以独立演进。模型接入层更换供应商时,服务编排层不需要修改;服务编排层新增 Agent 能力时,模型接入层也不感知。
模型接入层:适配器模式与统一接口
统一接口的必要性
OpenAI、Anthropic、Gemini 在认证方式、消息角色、流式传输和函数调用上存在明显差异。例如 Gemini 的模型回复使用 model 角色并携带 functionCall 块,函数结果通过 tool 角色的 functionResponse 块回传;Anthropic 的 tool_result 则嵌套在 user 消息的内容块中。协议不同,语义相似,因此需要一层适配器完成转换[1]。
统一接口定义
一个最小模型接入接口可以定义为:
typescript
interface ToolCall {
id: string;
name: string;
arguments: string; // JSON 字符串
}
interface ChatMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content?: string;
toolCallId?: string; // role 为 tool 时使用
toolCalls?: ToolCall[]; // role 为 assistant 时使用
}
interface Tool {
name: string;
description: string;
parameters: Record<string, unknown>;
}
interface ChatRequest {
model: string;
messages: ChatMessage[];
tools?: Tool[];
}
interface ChatResponse {
content: string;
toolCalls?: ToolCall[];
}
interface ChatAdapter {
chat(req: ChatRequest): Promise<ChatResponse>;
chatStream?(req: ChatRequest): AsyncIterable<ChatResponse>;
}这里的 ChatMessage 与 OpenAI 兼容格式相近,但独立于任何供应商。system 作为统一消息中的角色,由适配器在转换时映射到供应商的 system 指令字段。Anthropic 使用 system 参数,Gemini 使用 systemInstruction。
OpenAI 适配器示例
OpenAI 的 /v1/chat/completions 已成为实际上的兼容标准,llama.cpp 与 vLLM 都提供该端点[3]。下面是一个最小适配器:
typescript
export class OpenAIAdapter implements ChatAdapter {
constructor(
private apiKey: string,
private baseUrl = 'https://api.openai.com/v1',
) {}
async chat(req: ChatRequest): Promise<ChatResponse> {
const res = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: req.model,
messages: req.messages,
tools: req.tools,
}),
});
if (!res.ok) {
throw new Error(`OpenAI API error: ${res.status}`);
}
const data = await res.json();
const message = data.choices[0].message;
return {
content: message.content ?? '',
toolCalls: message.tool_calls?.map((call: any) => ({
id: call.id,
name: call.function.name,
arguments: call.function.arguments,
})),
};
}
}适配器本身是纯函数式封装:输入统一的 ChatRequest,输出统一的 ChatResponse,内部完成协议转换。示例没有实现流式版本。chatStream 需要解析 SSE 数据流,并使用 async generator 逐步输出结果。
适配器注册表
多个适配器可以通过 Map 注册表管理:
typescript
const adapters = new Map<string, ChatAdapter>();
export function registerAdapter(provider: string, adapter: ChatAdapter) {
adapters.set(provider, adapter);
}
export function getAdapter(provider: string): ChatAdapter {
const adapter = adapters.get(provider);
if (!adapter) {
throw new Error('adapter not found');
}
return adapter;
}注册表结构可以在启动时根据环境配置决定加载哪些供应商。
不同供应商的适配差异
Anthropic 适配器需要将统一消息中的 tool 角色转换为 user 消息,并将其中的内容包装为 tool_result 块。同时,tool_use 块的 id 要与 tool_result 的 tool_use_id 对应。
Gemini 适配器需要将 assistant 消息转换为 model 角色,通过 functionCall 块表达工具调用;将 tool 消息转换为 tool 角色,通过 functionResponse 块表达结果。客户端必须按 user → model → tool → model → tool 的顺序累积上下文[1]。
Ollama 默认的 /api/chat 格式与 OpenAI 不同。若使用 Ollama 的 OpenAI 兼容端点,则可以复用 OpenAI 适配器[3]。
模型接入层:重试、熔断、限流与语义缓存
重试
模型 API 调用可能因网络抖动、速率限制或服务器过载而失败。对可重试的错误,可以使用指数退避重试:
typescript
async function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function retry<T>(
fn: () => Promise<T>,
{ maxAttempts, baseDelay }: { maxAttempts: number; baseDelay: number },
): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt >= maxAttempts - 1) {
throw error;
}
await delay(baseDelay * 2 ** attempt);
}
}
}maxAttempts 和 baseDelay 是配置项,不同模型或租户可以使用不同值。注意,重试只应针对可重试错误,例如 429 限流或 5xx 服务器错误;认证错误和请求参数错误不应重试。
熔断
连续失败时,熔断器可以在短时间内快速失败,避免对上游造成压力。
typescript
class CircuitBreaker {
private failures = 0;
private openedAt = 0;
private state: 'closed' | 'open' | 'half-open' = 'closed';
constructor(
private threshold: number,
private cooldownMs: number,
) {}
async call<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === 'open') {
if (Date.now() - this.openedAt >= this.cooldownMs) {
this.state = 'half-open';
} else {
throw new Error('circuit open');
}
}
try {
const result = await fn();
this.reset();
return result;
} catch (error) {
this.failures++;
if (this.failures >= this.threshold) {
this.state = 'open';
this.openedAt = Date.now();
}
throw error;
}
}
private reset() {
this.failures = 0;
this.state = 'closed';
}
}熔断器通常在模型接入层包装适配器调用。打开期间直接抛错,上层可以走降级逻辑。
限流
限流可以防止请求频率超过模型供应商的配额。令牌桶是常用的实现:
typescript
class TokenBucket {
private tokens: number;
private lastRefill: number;
constructor(
private capacity: number,
private refillRate: number, // token/ms
) {
this.tokens = capacity;
this.lastRefill = Date.now();
}
take(): boolean {
this.refill();
if (this.tokens >= 1) {
this.tokens -= 1;
return true;
}
return false;
}
private refill() {
const now = Date.now();
const elapsed = now - this.lastRefill;
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate);
this.lastRefill = now;
}
}refillRate 需要根据每秒速率换算为每毫秒速率。可以再包一层,按秒配置,内部进行换算。
语义缓存
缓存可以降低成本和延迟。Portkey 的 LLM 网关提供两种缓存模式:Simple Cache 对输入 prompt 做精确匹配;Semantic Cache 基于余弦相似度判断输入与缓存请求的语义相似性,超过阈值即返回缓存结果[4]。
Portkey 的语义缓存只使用 user 消息的 content 计算相似度,忽略 system 消息,因此修改系统提示词不会影响命中[5]。
一个语义缓存的骨架如下:
typescript
class SemanticCache {
private entries: { embedding: number[]; response: string }[] = [];
constructor(private threshold: number) {}
get(embedding: number[]): string | null {
for (const entry of this.entries) {
const score = cosineSimilarity(embedding, entry.embedding);
if (score >= this.threshold) {
return entry.response;
}
}
return null;
}
set(embedding: number[], response: string) {
this.entries.push({ embedding, response });
}
}
function cosineSimilarity(a: number[], b: number[]): number {
// 根据所选嵌入模型实现
return 0;
}cosineSimilarity 需要与具体嵌入向量配合。阈值是配置项,不应在代码中固定。注意缓存返回 null 表示未命中,这与模型返回空字符串不同。
服务编排层:Prompt 管理、工具调用与 Agent 编排
Prompt 管理
Prompt 管理需要解决模板、变量和版本的问题。模板可以是一个函数:
typescript
const templates = new Map<string, (vars: Record<string, string>) => string>();
templates.set('translate', vars =>
`请将以下内容翻译成${vars.targetLanguage}:\n${vars.text}`,
);
export function renderPrompt(name: string, vars: Record<string, string>) {
const template = templates.get(name);
if (!template) {
throw new Error('prompt template not found');
}
return template(vars);
}模板函数比字符串替换更容易测试,也避免占位符冲突。如果模板存储在文件或远端配置服务中,则可以在不发布代码的情况下更新模板。
工具定义与注册
工具调用需要两个部分:模型看到的 JSON Schema,以及应用侧的执行函数。
typescript
type ToolHandler = (args: any) => Promise<unknown>;
const toolHandlers = new Map<string, ToolHandler>();
export function registerTool(name: string, handler: ToolHandler) {
toolHandlers.set(name, handler);
}
export function getTool(name: string): ToolHandler | undefined {
return toolHandlers.get(name);
}工具 Schema 可以单独注册,也可以从处理函数的元数据中读取。请求模型时,将 Schema 列表传给模型,模型再决定是否调用。
工具调用循环
一个最基本的工具调用循环如下:
typescript
const MAX_ITERATIONS = Number(process.env.AGENT_MAX_ITERATIONS) || 10;
interface AgentResult {
response: ChatResponse;
messages: ChatMessage[];
}
async function runAgent(
adapter: ChatAdapter,
req: ChatRequest,
): Promise<AgentResult> {
const messages = [...req.messages];
for (let i = 0; i < MAX_ITERATIONS; i++) {
const res = await adapter.chat({ ...req, messages });
if (!res.toolCalls || res.toolCalls.length === 0) {
messages.push({ role: 'assistant', content: res.content });
return { response: res, messages };
}
messages.push({
role: 'assistant',
content: res.content,
toolCalls: res.toolCalls,
});
for (const call of res.toolCalls) {
const handler = getTool(call.name);
const result = handler
? await handler(JSON.parse(call.arguments))
: { error: 'tool not found' };
messages.push({
role: 'tool',
toolCallId: call.id,
content: JSON.stringify(result),
});
}
}
throw new Error('tool call loop exceeded limit');
}循环会在达到最大迭代次数时停止。消息顺序必须是 assistant 的 toolCalls 与后续 tool 消息一一对应,这与 Gemini 和 Anthropic 的累积要求一致[1]。
RAG 的工具化接入
RAG 可以封装成一个工具。例如 search_knowledge_base 负责向量检索,返回候选文档:
typescript
registerTool('search_knowledge_base', async (args: { query: string }) => {
const docs = await vectorSearch(args.query);
return docs.map(doc => doc.text).slice(0, 3);
});模型在需要检索时会调用这个工具,应用把检索结果放入 tool 消息中。这样知识库的索引方式对模型完全透明。
Agent 编排
Agent 编排是在工具循环之上增加任务分解和状态。任务分解通常依赖模型对目标的规划,但编排逻辑本身由代码控制。安全策略也应放在这一层,例如输入输出过滤和审计记录。
注意,不要在提示词中要求模型“忽略之前指令”作为安全边界。这种约束不可验证,真正的边界必须写在代码里。
会话与状态管理:上下文窗口与外置会话
无状态服务与会话外置
模型 API 无状态,应用服务也应尽量保持无状态,方便水平扩展。会话消息历史需要外置存储,例如 Redis。每个会话用 sessionId 标识,服务实例从存储中读取历史,调用模型后将新消息写回。
typescript
class SessionStore {
private sessions = new Map<string, ChatMessage[]>();
get(sessionId: string): ChatMessage[] {
return this.sessions.get(sessionId) ?? [];
}
set(sessionId: string, messages: ChatMessage[]) {
this.sessions.set(sessionId, messages);
}
}这是内存实现,实际部署中应使用 Redis 等外部存储。同一个会话的并发请求需要保证消息顺序,可以通过串行化请求或使用原子追加实现。
上下文窗口管理
消息历史不断增长后,需要裁剪或压缩。简单策略是只保留最近 N 条消息,同时保留 system 消息。更好的做法是按 token 数裁剪:
typescript
function trimMessages(
messages: ChatMessage[],
maxTokens: number,
countTokens: (m: ChatMessage) => number,
): ChatMessage[] {
let total = 0;
const kept: ChatMessage[] = [];
for (let i = messages.length - 1; i >= 0; i--) {
const cost = countTokens(messages[i]);
if (total + cost > maxTokens) break;
kept.unshift(messages[i]);
total += cost;
}
const system = messages.find(m => m.role === 'system');
if (system && !kept.includes(system)) {
kept.unshift(system);
}
return kept;
}countTokens 需要按模型自己的 tokenizer 计算。不同模型的 tokenizer 不同,使用字符长度近似会产生偏差。部分供应商提供 SDK 或计数 API,应优先使用。
上下文压缩
当历史过长时,可以将旧消息压缩成摘要,并把摘要放在上下文中。压缩比裁剪更贵,但能保留更多信息。是否使用压缩取决于具体场景。
会话恢复与多轮约束
恢复会话时,必须保持消息格式的连续性。Gemini 要求消息按 user → model → tool 顺序交替[1];Anthropic 要求 tool_use 的 assistant 消息和 tool_result 的 user 消息连续回传。因此 SessionStore 保存的应该是统一格式的历史消息,适配层在请求时再转换。
应用运行时:部署形态、异步流式与弹性伸缩
部署形态
LLM 应用可以部署为容器服务、Kubernetes Deployment 或 Serverless 函数。容器和 K8s 适合长连接和流式响应;Serverless 适合短请求,但流式输出和冷启动需要专门处理。GPU 推理服务通常需要常驻资源,这会影响整体部署策略。
异步流式
流式响应可以用 async generator 表示:
typescript
async function* streamChat(
adapter: ChatAdapter,
req: ChatRequest,
): AsyncIterable<string> {
const iterator = adapter.chatStream?.(req);
if (!iterator) {
const response = await adapter.chat(req);
yield response.content;
return;
}
for await (const chunk of iterator) {
yield chunk.content;
}
}外层服务可以将输出封装为 SSE 格式:
typescript
import type { ServerResponse } from 'node:http';
async function writeSse(res: ServerResponse, stream: AsyncIterable<string>) {
for await (const text of stream) {
res.write(`data: ${text}\n\n`);
}
res.write('data: [DONE]\n\n');
res.end();
}流式适配器需要解析各供应商的 SSE 结构,并将文本内容转换为统一 chunk。
弹性伸缩
无状态服务可以通过增加实例来扩展。会话外置后,新实例可以读取同一份会话数据。流式响应对负载均衡器的超时配置有要求,长连接不能过早中断。扩容时还需要考虑下游模型 API 的速率限制,实例数增加可能会导致请求超出配额。
冷启动
冷启动主要影响 Serverless 和 GPU 推理服务。Serverless 实例在无请求时会被回收,首个请求需要初始化进程。GPU 推理服务需要加载模型权重,加载时间可能很长。使用 Serverless 时可以考虑保留最小实例数,或选择启动更快的推理后端。
安全合规、可观测性与成本治理
密钥管理
模型 API 密钥是敏感信息,不应硬编码在代码中。通常从环境变量或密钥管理服务读取。容器部署时也可以通过挂载密钥文件读取。
typescript
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new Error('OPENAI_API_KEY is not set');
}日志中不能出现密钥。请求体和响应体如果包含敏感用户数据,也需要脱敏后才可记录。
多租户隔离
多租户系统必须在请求链路中保留租户标识。会话键可以包含租户 ID:
typescript
const storageKey = `session:${tenantId}:${sessionId}`;这样可以避免不同租户之间的会话串号。模型调用如果使用共享密钥,还需要按租户做用量限制。
可观测性
每个请求需要记录至少以下字段:租户 ID、模型名称、输入 token、输出 token、耗时、是否命中缓存、是否重试。流式请求还应该记录首个 token 到达时间。
一个日志包装适配器:
typescript
class LoggingAdapter implements ChatAdapter {
constructor(private inner: ChatAdapter) {}
async chat(req: ChatRequest): Promise<ChatResponse> {
const start = Date.now();
try {
const res = await this.inner.chat(req);
console.log(JSON.stringify({
model: req.model,
ms: Date.now() - start,
inputTokens: res.usage?.inputTokens,
outputTokens: res.usage?.outputTokens,
}));
return res;
} catch (error) {
console.error(JSON.stringify({ error: String(error) }));
throw error;
}
}
chatStream?(req: ChatRequest): AsyncIterable<ChatResponse> {
// 流式日志需要在适配器内部逐块记录
return this.inner.chatStream!(req);
}
}实际应用中,可以使用 OpenTelemetry 等标准工具上报 trace。日志字段以结构化为佳,便于集中采集。
成本追踪
成本可以基于 token 用量乘以模型单价计算。模型接入层返回 usage 后,服务层将用量写入成本表,按租户和模型维度聚合。流式响应的 usage 通常在最后一个 chunk 中返回,需要额外解析。
工程骨架实现:TypeScript 项目结构与关键链路
目录结构
一个最小工程骨架可以这样组织:
text
src/
adapters/
openai.ts
anthropic.ts
gemini.ts
index.ts
gateway/
retry.ts
circuitBreaker.ts
rateLimiter.ts
cache.ts
service/
prompt.ts
agent.ts
runtime/
session.ts
context.ts
stream.ts
app.tsadapters 存放各供应商适配器,gateway 存放稳定性组件,service 存放 Prompt 与 Agent 编排,runtime 存放会话与上下文管理。
依赖组装
构造函数注入可以使上层不依赖具体适配器实现:
typescript
class App {
constructor(
private adapter: ChatAdapter,
private store: SessionStore,
) {}
}
const adapter = new OpenAIAdapter(process.env.OPENAI_API_KEY ?? '');
const store = new SessionStore();
const app = new App(adapter, store);如果后续需要增加 Anthropic,只需注册另一个适配器,App 本身不需要改动。
关键链路
一个请求从进入应用到返回响应的链路可以概括为:
- HTTP 层根据请求头中的
sessionId从 SessionStore 读取消息历史。 - 服务编排层将用户消息追加到历史,调用
runAgent执行工具循环。 - 模型接入层通过适配器调用模型 API,外层叠加重试、熔断、限流和缓存。
- 返回的模型回复写回 SessionStore,同时输出给 HTTP 层。
typescript
async function handleRequest(
adapter: ChatAdapter,
store: SessionStore,
sessionId: string,
userMessage: string,
): Promise<ChatResponse> {
const history = store.get(sessionId);
const messages = [
...history,
{ role: 'user' as const, content: userMessage },
];
const { response, messages: updated } = await runAgent(adapter, {
model: 'default',
messages,
});
store.set(sessionId, updated);
return response;
}这个示例中,runAgent 返回完整消息列表,因此工具调用产生的中间消息也会被保存。后续轮次可以基于完整历史继续生成。
生态标准与演进方向:MCP、LLM 网关与 Agent 平台
MCP
Model Context Protocol(MCP)是 Anthropic 发布的开放工具调用协议,目标是标准化模型与应用之间的工具交互[2]。MCP 引入客户端、服务端和工具调用流程。服务端暴露一组工具,客户端可以在模型需要时调用这些工具。MCP 解决的是工具接入的互操作问题,不会完全替代模型接入层,但会影响接入层与服务层之间的边界。
LLM 网关
LiteLLM、Portkey 等 LLM 网关提供统一入口,支持多模型路由、重试、限流和缓存[4]。Portkey 的缓存支持简单匹配和语义匹配[4]。这类网关可以让应用不需要自己实现重试和缓存,但需要评估是否支持目标供应商的全部协议特性,例如流式工具调用和视觉输入。
Agent 平台
Google 发布 Agent Development Kit(ADK),用于构建支持多种模型的 Agent,并提供多模型接入与工具注册机制[2]。如果目标是构建一个可被任意 Agent 访问的自定义服务,则基于 MCP 实现服务端更为直接。这类平台仍依赖模型接入层来完成底层协议转换。
对架构的影响
随着 MCP 和 LLM 网关的成熟,模型接入层的部分职责可以外移。应用层可以选择使用自定义适配器、接入现成网关,或同时使用两者。三层结构的边界不会消失,但每一层的内涵会随着生态标准演进而变化。
