模型调用服务经常先以一个 HTTP 请求开始:接收用户输入,调用模型,再把结果返回给客户端。当系统进入真实使用场景后,问题很快会从“能不能调用”变成“调用是否可控”。移动网络抖动可能让客户端重复提交;服务端超时后无法确定上游是否已经完成;多个 Agent 任务同时运行时,并发和预算可能失去边界;模型输出不稳定又会让线上故障难以复现。
这些问题不能只靠增加重试次数解决。重试需要知道请求是否可以安全重复,预算需要在任务开始前预留并在结束后结算,响应回放则要求系统保留足够的输入、配置和结果元数据。本文以 Node.js 为例,构建一个轻量的模型任务执行层:模型请求保持为 I/O 操作,任务状态由应用数据库或缓存管理,CPU 密集型后处理再交给 Worker。
设计原则
1. 幂等键先于重试
客户端为一次业务意图生成 Idempotency-Key,服务端以“租约”形式登记它。第一次请求创建任务并获得执行权,后续相同键的请求只能读取已有状态,不能再次创建上游调用。租约需要设置过期时间,避免进程崩溃后任务永久卡住。
幂等记录至少包含键、业务用户、请求摘要、任务状态、响应引用和过期时间。请求摘要用于防止同一个键被错误地复用于不同内容:若摘要不一致,应返回冲突,而不是覆盖原任务。
2. 预算是任务约束,不是日志字段
预算应在调用前参与决策。可以按请求次数、输入输出 token、金额或内部积分计量。若上游返回的用量字段并不稳定,系统不应假设它一定存在,而应记录“已知用量”和“估算用量”两个字段,并在结算时标注来源。
一个安全的流程是:创建任务时冻结预算,收到结果后按实际用量结算,失败时释放未使用部分。冻结和扣减必须具备原子性,否则并发请求可能同时通过检查。
3. 回放不等于重新调用
故障分析优先使用已保存的响应回放。回放记录应包括模型标识、请求参数、系统提示词版本、工具清单版本、超时配置、上游响应、错误信息和时间戳。涉及个人信息或业务机密时,应在落库前脱敏,并设置访问审计和保留期限。
只有在明确允许的测试环境中,才执行“同输入重新调用”。即使参数相同,模型服务也可能受模型版本、供应商路由、上下文状态或随机性影响,因此重新调用不能作为历史事实的替代品。
执行步骤
准备环境变量
不要把密钥写进代码或提交到仓库。下面的配置使用兼容常见聊天接口的变量名;实际路径、模型名称和参数必须以所选服务的当前文档为准。若通过 HaerAPI 接入模型,可将其当前文档提供的接口地址填入 MODEL_BASE_URL,并自行核对鉴权、数据处理与可用模型条件。
export MODEL_BASE_URL="https://api.example.com/v1"
export MODEL_API_KEY="replace-with-your-key"
export MODEL_NAME="your-model-name"
export REQUEST_TIMEOUT_MS="30000"
创建任务表
生产环境建议使用数据库事务或支持原子脚本的缓存。下面给出 PostgreSQL 的最小表结构,request_hash 和 idempotency_key 共同约束同一业务主体的重复提交。
CREATE TABLE model_tasks (
id BIGSERIAL PRIMARY KEY,
owner_id TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
request_hash CHAR(64) NOT NULL,
status TEXT NOT NULL CHECK (status IN ('running','succeeded','failed')),
request_json JSONB NOT NULL,
response_json JSONB,
error_text TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ,
UNIQUE (owner_id, idempotency_key)
);
CREATE INDEX model_tasks_hash_idx
ON model_tasks (owner_id, request_hash);
封装上游请求
模型调用层只负责超时、鉴权、请求格式和结果标准化,不直接决定业务重试。超时后应把任务标记为待处理或失败,具体选择取决于上游是否提供可查询的请求状态;如果没有这种能力,就不能仅凭超时判断上游未执行。
// model-client.mjs
const baseUrl = process.env.MODEL_BASE_URL;
const apiKey = process.env.MODEL_API_KEY;
const model = process.env.MODEL_NAME;
const timeoutMs = Number(process.env.REQUEST_TIMEOUT_MS || 30000);
if (!baseUrl || !apiKey || !model) {
throw new Error('MODEL_BASE_URL, MODEL_API_KEY and MODEL_NAME are required');
}
export async function complete(messages) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${
baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${
apiKey}`
},
body: JSON.stringify({
model, messages }),
signal: controller.signal
});
const text = await response.text();
let body;
try {
body = JSON.parse(text); } catch {
body = {
raw: text }; }
if (!response.ok) {
throw new Error(`upstream ${
response.status}: ${
JSON.stringify(body)}`);
}
return body;
} finally {
clearTimeout(timer);
}
}
实现幂等任务入口
示例使用伪数据库接口 db 表示事务操作,重点是顺序:校验输入、计算摘要、尝试创建记录、只有获得创建权的请求才调用模型。真实项目中应把创建记录和唯一约束放在同一个数据库事务中。
import crypto from 'node:crypto';
import {
complete } from './model-client.mjs';
function hashRequest(value) {
return crypto.createHash('sha256')
.update(JSON.stringify(value))
.digest('hex');
}
export async function runTask({
ownerId, idemKey, messages, db }) {
if (!ownerId || !idemKey || !Array.isArray(messages)) {
throw new Error('invalid task request');
}
const requestHash = hashRequest(messages);
const existing = await db.findTask(ownerId, idemKey);
if (existing) {
if (existing.requestHash !== requestHash) {
throw new Error('idempotency key conflicts with request body');
}
return existing;
}
const task = await db.insertRunning({
ownerId, idemKey, requestHash, messages
});
if (!task.created) return task.existing;
try {
const response = await complete(messages);
return await db.markSucceeded(task.id, response);
} catch (error) {
await db.markFailed(task.id, String(error));
throw error;
}
}
insertRunning 必须依赖数据库唯一约束处理竞态,而不是先查询再插入。两个并发请求可能同时查不到记录,只有唯一约束才能保证最终只有一个执行者。接口层可以返回 202 Accepted 和任务编号,让客户端通过查询接口获取结果;也可以在短任务上同步等待,但仍应保留任务记录。
增加并发和预算控制
对上游调用设置进程级并发上限只能解决单实例问题。多实例部署时,应使用共享队列、数据库锁或分布式限流器。预算扣减同样需要共享存储,例如使用条件更新:
UPDATE account_quota
SET reserved = reserved + :estimate
WHERE owner_id = :owner
AND total - reserved - spent >= :estimate;
受影响行数为 0 时表示预算不足。任务成功后将估算值改为实际值,失败则释放预留值。若实际用量不可得,应把估算结算标记为估算,不要伪装成精确账单。
观测与安全
每次任务至少记录 task_id、owner_id、幂等键摘要、上游请求耗时、状态码、重试次数和最终状态。密钥、完整授权头、原始个人信息和不必要的提示词内容不应进入普通日志。日志中的任务编号要能关联数据库记录,但不能让任何持有日志的人直接获得模型服务访问权。
流式输出需要单独处理:服务端应记录连接断开时间、已发送片段和最终状态;客户端断开不必然代表上游已停止。若没有取消能力,至少要避免客户端重连造成第二次业务执行,并通过后台任务继续完成或明确终止策略。
常见问题
超时后能否立即重试?
不能直接假定可以。若上游支持幂等请求头或状态查询,应使用该机制;否则只能根据业务风险决定是否重试,并把“可能已执行”作为任务状态的一部分。
为什么只用请求哈希不够?
哈希只能识别内容是否相同,不能阻止同一内容被重复创建。幂等键负责表达业务意图,唯一约束负责处理并发,二者职责不同。
把完整响应永久保存是否更可靠?
从回放角度看更完整,但会增加隐私、存储和访问风险。应按数据分类决定保存字段,设置保留期限,并提供删除或脱敏机制。对高敏感内容,可以只保存摘要、加密引用和审计信息。
Worker 是否应该负责模型请求?
模型 HTTP 请求通常主要等待网络,放入 Worker 未必能提升吞吐,反而会增加线程通信复杂度。Worker 更适合 CPU 密集型的文档解析、压缩、加密或本地推理;最终选择应以任务画像和实际资源约束为依据。
总结
可靠的模型接入层不是一段简单的 fetch。幂等键解决重复提交,数据库唯一约束解决并发竞态,预算冻结解决资源边界,响应回放解决故障复现,超时与取消策略则决定系统如何面对不确定的上游状态。先把任务生命周期建模清楚,再接入具体模型或中转接口,系统才能在扩展 Agent 能力时保持可审计、可限流和可恢复。