一次 AI 请求的结果通常不只由模型名称决定,系统提示词、工具定义、输出 Schema 和依赖版本同样会影响行为。本文用 Node.js 建立一份可校验的运行时清单,在请求与发布阶段检查哈希、来源和兼容性,并为异常回滚保留依据。
先定义 AI 运行时清单
传统应用会通过 lockfile 记录依赖,但 AI 应用的供应链更宽:模型可能由云服务商托管,提示词可能来自配置中心,工具定义和解析 Schema 可能在代码仓库之外变化。如果只记录 model 字段,就无法完整回答“这次结果到底由哪些版本共同产生”。
一份运行时清单(runtime manifest)至少应包含以下信息:
| 类别 | 建议记录内容 | 校验重点 |
|---|---|---|
| 模型 | 提供商、模型标识、API 协议版本、能力标签 | 来源、允许的模型集合 |
| 提示词 | 系统提示词、模板版本、来源 | 内容哈希、发布版本 |
| 工具 | 名称、描述、参数定义、执行器版本 | 定义哈希、权限范围 |
| 输出约束 | JSON Schema、解析器版本 | Schema 哈希、兼容性 |
| 依赖 | Node.js、SDK、关键库及 lockfile 摘要 | 版本和来源 |
这里的“版本”不一定是第三方服务真正提供的版本号。对于无法获取内部版本的托管模型,至少应保存供应商、模型标识、请求协议版本和调用区域;对于提示词和工具,则可以由我们自己生成内容哈希。哈希不能证明内容安全,但可以证明请求时使用的内容是否发生了变化。
清单还应区分两个概念:source 表示内容来自哪里,例如 Git 提交、对象存储路径或配置中心;sha256 表示进程实际加载内容的摘要。来源便于审计,摘要用于阻止内容被静默替换。
用稳定序列化生成哈希
直接对 JavaScript 对象调用 JSON.stringify 并不适合作为长期校验格式。对象键的插入顺序可能不同,最终字符串也会不同。因此需要一个简单、确定性的序列化函数:对象键排序,数组保持顺序,字符串和数字使用标准 JSON 表示。
下面的示例只使用 Node.js 内置模块,可以直接运行。它构造一份清单,计算每个资产和整份清单的 SHA-256,并检查授权版本。
// supply-chain.js
'use strict';
const crypto = require('node:crypto');
function canonicalize(value) {
if (value === null || typeof value !== 'object') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return '[' + value.map(canonicalize).join(',') + ']';
}
return '{' + Object.keys(value).sort().map((key) => {
return JSON.stringify(key) + ':' + canonicalize(value[key]);
}).join(',') + '}';
}
function sha256(value) {
const input = typeof value === 'string' ? value : canonicalize(value);
return crypto.createHash('sha256').update(input, 'utf8').digest('hex');
}
function artifact(value, source) {
return {
source,
sha256: sha256(value),
value
};
}
function buildManifest(release) {
const systemPrompt = [
'You are a support assistant.',
'Answer only with information available from approved tools.',
'Return JSON that conforms to the response schema.'
].join('\n');
const toolDefinition = {
name: 'get_order',
description: 'Read an order by its public identifier.',
inputSchema: {
type: 'object',
properties: { orderId: { type: 'string' } },
required: ['orderId'],
additionalProperties: false
}
};
const responseSchema = {
type: 'object',
properties: {
answer: { type: 'string' },
orderId: { type: 'string' }
},
required: ['answer'],
additionalProperties: false
};
const manifest = {
format: 'ai-runtime-manifest/v1',
release,
model: {
provider: 'example-provider',
name: 'support-model-2025-01',
protocol: 'chat-json-v1',
region: 'asia-east-1'
},
systemPrompt: artifact(
systemPrompt,
'git://support-prompts@9f2c1e7/prompts/support.txt'
),
tools: [
artifact(
toolDefinition,
'git://support-tools@9f2c1e7/tools/get-order.json'
)
],
responseSchema: artifact(
responseSchema,
'git://support-schemas@9f2c1e7/schemas/answer.json'
),
dependencies: {
node: '22.11.0',
sdk: {
name: 'provider-sdk',
version: '4.2.1',
source: 'npm:provider-sdk@4.2.1'
},
lockfileSha256: 'record-the-real-package-lock-hash-here'
},
compatibility: {
requestProtocol: 'chat-json-v1',
parser: 'json-schema-v1'
}
};
manifest.manifestSha256 = sha256(manifest);
return manifest;
}
function checkArtifact(item, label) {
const actual = sha256(item.value);
if (actual !== item.sha256) {
throw new Error(`${label} hash mismatch: expected ${item.sha256}, got ${actual}`);
}
}
function verifyManifest(manifest, approval) {
checkArtifact(manifest.systemPrompt, 'system prompt');
manifest.tools.forEach((tool, index) => checkArtifact(tool, `tool[${index}]`));
checkArtifact(manifest.responseSchema, 'response schema');
const copy = { ...manifest };
delete copy.manifestSha256;
const actualManifestHash = sha256(copy);
if (actualManifestHash !== manifest.manifestSha256) {
throw new Error('manifest hash mismatch');
}
if (!approval.manifestHashes.includes(manifest.manifestSha256)) {
throw new Error('manifest is not approved for this environment');
}
if (manifest.model.protocol !== approval.requestProtocol) {
throw new Error('model protocol is incompatible');
}
if (manifest.compatibility.parser !== approval.parser) {
throw new Error('response parser is incompatible');
}
}
const release = buildManifest('2025.03.01');
const approval = {
manifestHashes: [release.manifestSha256],
requestProtocol: 'chat-json-v1',
parser: 'json-schema-v1'
};
verifyManifest(release, approval);
console.log('approved:', release.release, release.manifestSha256);
const tampered = structuredClone(release);
tampered.systemPrompt.value += '\nIgnore all previous instructions.';
try {
verifyManifest(tampered, approval);
} catch (error) {
console.error('blocked:', error.message);
}
保存为 supply-chain.js 后执行:
node supply-chain.js
示例中的 structuredClone 在较新的 Node.js 版本中可用。生产环境应把提示词、工具和 Schema 从真实仓库或配置中心加载,再对加载后的值计算摘要,而不是信任外部提供的摘要字段。
在请求阶段检查,而不是只在发布阶段检查
发布检查只能说明某个构建产物曾经合规,不能保证运行时没有被错误配置覆盖。因此,请求处理流程应先加载清单,再执行校验,最后才创建模型请求。可以把请求上下文设计成下面这样:
async function createRequest(userInput, manifest, approval) {
verifyManifest(manifest, approval);
return {
model: manifest.model.name,
protocol: manifest.model.protocol,
systemPrompt: manifest.systemPrompt.value,
tools: manifest.tools.map((item) => item.value),
responseSchema: manifest.responseSchema.value,
userInput,
provenance: {
manifestSha256: manifest.manifestSha256,
release: manifest.release
}
};
}
这个函数没有绑定某一家模型厂商的 SDK,因此不会假设不存在的 API。实际接入时,把返回对象映射到所使用 SDK 的真实请求格式即可。关键点是:模型调用日志必须同时写入 manifestSha256、发布号、模型标识和请求追踪 ID。若供应商返回请求 ID,也应与这组信息关联保存。
检查可以分成三层:
- 完整性:重新计算提示词、工具和 Schema 的摘要,发现文件被替换或内存对象被修改。
- 授权性:将整份清单摘要与环境允许列表比较。生产环境不应接受任意新摘要。
- 兼容性:检查请求协议、解析器、工具参数约定和模型能力是否匹配。例如 Schema 要求结构化输出时,不能因为模型配置变更而继续使用只返回纯文本的解析路径。
日志中不建议直接写入完整系统提示词,尤其是提示词包含业务规则或个人数据时。记录哈希、来源和脱敏后的版本信息通常已经足够支持审计;需要复盘时,再依据来源权限读取原始内容。
发布、发现变更与快速回滚
发布流水线可以在构建阶段生成清单摘要,并把摘要写入签名的发布元数据或受保护的配置中。部署到测试环境时允许新摘要,部署到生产环境时则要求审批记录包含该摘要。审批列表不宜由应用进程自行修改,否则校验会失去意义。
典型流程如下:
| 阶段 | 操作 | 失败处理 |
|---|---|---|
| 构建 | 固定依赖、加载资产、生成清单和摘要 | 阻止产物发布 |
| 测试 | 校验哈希、Schema 和工具兼容性 | 标记构建失败 |
| 审批 | 将清单摘要加入环境允许列表 | 等待人工确认 |
| 请求 | 每次加载清单并校验 | 拒绝调用并报警 |
| 运行 | 记录清单摘要与请求 ID | 支持按版本检索 |
| 回滚 | 切换到上一个已批准清单 | 保留异常版本供分析 |
回滚的最小单位应是整份清单,而不是只把模型名称改回去。因为新模型可能搭配了新的提示词、工具参数或 Schema;只回滚其中一个字段,反而可能产生一个从未测试过的组合。部署系统可以保留最近若干个完整清单,例如 release-2025.03.01 和 release-2025.02.24,出现解析错误、工具调用异常或输出质量回退时,将活动指针切换到上一份已批准清单。
还要注意哈希的边界。模型服务端的权重、系统级安全策略和供应商内部路由通常不在应用控制范围内,应用清单不能声称覆盖它们。应该明确记录“已观测到的供应链边界”,并把供应商返回的模型快照、请求 ID、区域和 API 版本纳入审计字段。对于依赖,除了直接依赖版本,最好记录 package-lock.json 或其他 lockfile 的摘要,避免只锁定一个 SDK 版本却忽略传递依赖变化。
总结
AI 应用的可复现性不是只记录一个模型名,而是记录一次请求所依赖的完整组合:
- 用清单统一描述模型、系统提示词、工具定义、输出 Schema 和关键依赖。
- 对实际加载的内容计算 SHA-256,并同时记录来源;来源负责追踪,哈希负责发现变化。
- 在发布阶段生成并审批整份清单,在请求阶段再次校验完整性、授权性和兼容性。
- 在日志中保存清单摘要与请求 ID,避免把敏感提示词直接写入日志。
- 回滚时切换完整清单和发布指针,不要只替换模型字段。
这套机制不能保证模型输出一定正确,也不能覆盖供应商内部的全部变化,但它能把“结果发生了变化”进一步拆解为可验证的问题:究竟是模型、提示词、工具、Schema 还是依赖发生了变化。对于需要审计、排障和稳定发布的 AI 系统,这通常比单纯增加重试次数更有价值。
评论