fix: 优化 AI 上下文缓存与书库搜索排版并发布 v2.1.7

This commit is contained in:
lofyer
2026-08-10 09:53:49 +08:00
parent 26c7aaff66
commit e36c083751
10 changed files with 378 additions and 89 deletions
+150 -37
View File
@@ -193,7 +193,7 @@ test('Anthropic 接口使用原生 Messages 图像 source 和流式事件', asyn
assert.strictEqual(seen.options.headers['anthropic-version'], '2023-06-01');
assert.ok(!seen.options.headers.Authorization);
assert.strictEqual(seen.body.system.includes('文档页面图像'), true);
assert.strictEqual(seen.body.messages.length, 1);
assert.strictEqual(seen.body.messages.length, 3);
assert.strictEqual(seen.body.messages[0].content[0].type, 'text');
const image = seen.body.messages[0].content[1];
assert.strictEqual(image.type, 'image');
@@ -201,6 +201,12 @@ test('Anthropic 接口使用原生 Messages 图像 source 和流式事件', asyn
assert.strictEqual(image.source.type, 'base64');
assert.strictEqual(image.source.media_type, 'image/png');
assert.ok(image.source.data.length > 0);
assert.deepStrictEqual(
seen.body.messages[0].content.at(-1).cache_control,
{ type: 'ephemeral' },
'稳定资料前缀末尾必须声明 Anthropic 缓存断点'
);
assert.strictEqual(seen.body.messages[2].content, '问题:图中是什么?');
});
test('OpenAI Responses 接口使用 input_image 和响应增量事件', async () => {
@@ -387,9 +393,9 @@ test('不支持的任务类型被拒绝', () => {
test('ask 任务把问题与片段一起送出', () => {
const ai = setup();
const msgs = ai.buildMessages('ask', '文档内容', '这讲了什么');
assert.strictEqual(msgs.length, 2);
assert.strictEqual(msgs.length, 4);
assert.ok(msgs[1].content.includes('文档内容'));
assert.ok(msgs[1].content.includes('这讲了什么'));
assert.ok(msgs[3].content.includes('这讲了什么'));
assert.ok(/编造|没有提到/.test(msgs[0].content), '缺少防幻觉约束');
});
@@ -429,19 +435,23 @@ async function captureBody(protocol, args, options) {
// history 缺省时请求体必须与单轮时代逐字节一致,否则等于悄悄改了单轮行为
test('未传 history 时三种协议请求体与单轮完全一致', async () => {
// 写死单轮的 user 正文,只比对"传与不传 history"两次结果会同时被同一个 bug 污染
const expectedUser = '文档片段:\n"""\n正文\n"""\n\n问题:问题';
// 写死资料与当前问题的分层,只比对"传与不传 history"会同时被同一个 bug 污染
const expectedContext = '文档片段:\n"""\n正文\n"""';
const expectedQuestion = '问题:问题';
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
const base = await captureBody(protocol, { task: 'ask', text: '正文', question: '问题' });
if (protocol === 'chat-completions') {
assert.strictEqual(base.messages.length, 2, '单轮只该有 system + user');
assert.strictEqual(base.messages[1].content, expectedUser);
assert.deepStrictEqual(base.messages.map((m) => m.role), ['system', 'user', 'assistant', 'user']);
assert.strictEqual(base.messages[1].content, expectedContext);
assert.strictEqual(base.messages[3].content, expectedQuestion);
} else if (protocol === 'anthropic') {
assert.strictEqual(base.messages.length, 1, '单轮只该有一条 user');
assert.strictEqual(base.messages[0].content, expectedUser);
assert.deepStrictEqual(base.messages.map((m) => m.role), ['user', 'assistant', 'user']);
assert.strictEqual(base.messages[0].content[0].text, expectedContext);
assert.strictEqual(base.messages[2].content, expectedQuestion);
} else {
assert.strictEqual(base.input.length, 1, '单轮只该有一条 user');
assert.deepStrictEqual(base.input[0].content, [{ type: 'input_text', text: expectedUser }]);
assert.deepStrictEqual(base.input.map((m) => m.role), ['user', 'assistant', 'user']);
assert.deepStrictEqual(base.input[0].content, [{ type: 'input_text', text: expectedContext }]);
assert.deepStrictEqual(base.input[2].content, [{ type: 'input_text', text: expectedQuestion }]);
}
for (const history of [undefined, null, [], 'not-an-array', {}]) {
const withArg = await captureBody(
@@ -471,15 +481,16 @@ test('chat-completions 把历史插在 system 之后、当前轮之前', async (
});
assert.deepStrictEqual(
body.messages.map((m) => m.role),
['system', 'user', 'assistant', 'user', 'assistant', 'user'],
'历史必须在 messages 里,且当前轮排最后'
['system', 'user', 'assistant', 'user', 'assistant', 'user', 'assistant', 'user'],
'资料前缀必须在历史之前,当前轮排最后'
);
assert.strictEqual(body.messages[1].content, '第一个问题');
assert.strictEqual(body.messages[2].content, '第一个回答');
assert.strictEqual(body.messages[3].content, '第二个问题');
assert.strictEqual(body.messages[4].content, '第二个回答');
assert.match(body.messages[5].content, /第三个问题/, '当前轮问题丢失');
assert.match(body.messages[5].content, /正文/, '当前轮正文丢失');
assert.match(body.messages[1].content, /正文/, '稳定资料前缀丢失');
assert.strictEqual(body.messages[3].content, '第一个问题');
assert.strictEqual(body.messages[4].content, '第一个回答');
assert.strictEqual(body.messages[5].content, '第二个问题');
assert.strictEqual(body.messages[6].content, '第二个回答');
assert.match(body.messages[7].content, /第三个问题/, '当前轮问题丢失');
assert.doesNotMatch(body.messages[7].content, /正文/, '正文不应跟着每轮问题放到变化的尾部');
assert.ok(
!/第一个问题/.test(body.messages[0].content),
'历史不该被塞进 system,模型会把它当成指令'
@@ -498,10 +509,14 @@ test('anthropic 历史进 messagessystem 仍在顶层', async () => {
});
assert.strictEqual(typeof body.system, 'string');
assert.ok(!/旧问题|旧回答/.test(body.system), 'Anthropic 的 system 是顶层字段,历史不该混进去');
assert.deepStrictEqual(body.messages.map((m) => m.role), ['user', 'assistant', 'user']);
assert.strictEqual(body.messages[0].content, '旧问题');
assert.strictEqual(body.messages[1].content, '旧回答');
assert.match(body.messages[2].content, /新问题/);
assert.deepStrictEqual(
body.messages.map((m) => m.role),
['user', 'assistant', 'user', 'assistant', 'user']
);
assert.match(body.messages[0].content[0].text, /正文/);
assert.strictEqual(body.messages[2].content, '旧问题');
assert.strictEqual(body.messages[3].content, '旧回答');
assert.match(body.messages[4].content, /新问题/);
assert.ok(!body.messages.some((m) => m.role === 'system'), 'system 不能出现在 messages 里');
});
@@ -519,13 +534,100 @@ test('openai-responses 历史进 inputinstructions 与 store:false 保持', a
assert.ok(!/旧问题|旧回答/.test(body.instructions), 'instructions 是顶层字段,历史不该混进去');
assert.strictEqual(body.store, false, 'store 必须保持 false,服务端不留存对话');
assert.ok(!('previous_response_id' in body), '多轮不能靠服务端留存,与 store:false 冲突');
assert.deepStrictEqual(body.input.map((m) => m.role), ['user', 'assistant', 'user']);
// 纯字符串是 Responses 输入消息的合法简写,避开 input_text 不接受 assistant 的限制
assert.strictEqual(body.input[0].content, '旧问题');
assert.strictEqual(body.input[1].content, '旧回答');
assert.ok(Array.isArray(body.input[2].content), '当前轮仍用结构化 content');
assert.strictEqual(body.input[2].content[0].type, 'input_text');
assert.match(body.input[2].content[0].text, /新问题/);
assert.deepStrictEqual(
body.input.map((m) => m.role),
['user', 'assistant', 'user', 'assistant', 'user']
);
assert.match(body.input[0].content[0].text, /正文/);
assert.strictEqual(body.input[2].content, '旧问题');
assert.strictEqual(body.input[3].content, '旧回答');
assert.ok(Array.isArray(body.input[4].content), '当前轮仍用结构化 content');
assert.strictEqual(body.input[4].content[0].type, 'input_text');
assert.match(body.input[4].content[0].text, /新问题/);
});
test('三种协议都把未变化资料放在历史之前形成稳定缓存前缀', async () => {
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
const first = await captureBody(protocol, {
task: 'ask',
text: '固定全文资料',
question: '问题一',
history: []
});
const second = await captureBody(protocol, {
task: 'ask',
text: '固定全文资料',
question: '问题二',
history: [
{ role: 'user', text: '问题一' },
{ role: 'assistant', text: '回答一' }
]
});
if (protocol === 'chat-completions') {
assert.deepStrictEqual(first.messages.slice(0, 3), second.messages.slice(0, 3));
assert.strictEqual(second.messages.at(-1).content, '问题:问题二');
} else if (protocol === 'anthropic') {
assert.deepStrictEqual(first.messages.slice(0, 2), second.messages.slice(0, 2));
assert.strictEqual(second.messages.at(-1).content, '问题:问题二');
} else {
assert.deepStrictEqual(first.input.slice(0, 2), second.input.slice(0, 2));
assert.strictEqual(second.input.at(-1).content[0].text, '问题:问题二');
}
}
});
test('OpenAI 官方 GPT-5.6 请求使用稳定缓存键和显式资料断点', async () => {
for (const protocol of ['chat-completions', 'openai-responses']) {
const options = {
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-5.6'
};
const first = await captureBody(protocol, {
task: 'ask',
text: '固定全文资料',
question: '问题一'
}, options);
const second = await captureBody(protocol, {
task: 'ask',
text: '固定全文资料',
question: '问题二',
history: [
{ role: 'user', text: '问题一' },
{ role: 'assistant', text: '回答一' }
]
}, options);
const changed = await captureBody(protocol, {
task: 'ask',
text: '另一份全文资料',
question: '问题三'
}, options);
assert.match(first.prompt_cache_key, /^peoplelib-[0-9a-f]{48}$/);
assert.strictEqual(first.prompt_cache_key, second.prompt_cache_key);
assert.notStrictEqual(first.prompt_cache_key, changed.prompt_cache_key);
assert.deepStrictEqual(first.prompt_cache_options, { mode: 'explicit' });
const firstItem = protocol === 'chat-completions' ? first.messages[1] : first.input[0];
assert.deepStrictEqual(
firstItem.content.at(-1).prompt_cache_breakpoint,
{ mode: 'explicit' }
);
}
});
test('OpenAI 兼容接口不接收官方专用缓存字段', async () => {
for (const protocol of ['chat-completions', 'openai-responses']) {
const body = await captureBody(protocol, {
task: 'ask',
text: '固定全文资料',
question: '问题'
}, {
baseUrl: 'https://compatible.example.com/v1',
model: 'gpt-5.6'
});
assert.ok(!('prompt_cache_key' in body));
assert.ok(!('prompt_cache_options' in body));
assert.doesNotMatch(JSON.stringify(body), /prompt_cache_breakpoint/);
}
});
test('历史里的图像不被重发,只发当前轮的图', async () => {
@@ -580,8 +682,11 @@ test('anthropic 历史领头是 assistant 时首条仍是 user', async () => {
});
// Anthropic 的 /messages 直接 400 拒绝领头 assistant
assert.strictEqual(body.messages[0].role, 'user', '首条必须是 user,否则 Anthropic 直接 400');
assert.deepStrictEqual(body.messages.map((m) => m.role), ['user', 'assistant', 'user']);
assert.strictEqual(body.messages[0].content, '真正的第一问');
assert.deepStrictEqual(
body.messages.map((m) => m.role),
['user', 'assistant', 'user', 'assistant', 'user']
);
assert.strictEqual(body.messages[2].content, '真正的第一问');
});
test('历史末条是 user 时与当前轮合并,两段文本都保留', async () => {
@@ -624,7 +729,7 @@ test('历史内部相邻同角色被合并而不是丢弃', async () => {
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
assert.deepStrictEqual(
roles,
['user', 'assistant', 'user'],
['user', 'assistant', 'user', 'assistant', 'user'],
`${protocol} 未合并相邻同角色,Anthropic 会直接 400`
);
const flat = JSON.stringify(items);
@@ -657,7 +762,11 @@ test('历史含空文本时不产生空 content', async () => {
assert.ok(text && text.trim(), `${protocol} 出现空 contentAnthropic 不接受空字符串`);
}
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
assert.deepStrictEqual(roles, ['user', 'assistant', 'user'], `${protocol} 空消息未被过滤干净`);
assert.deepStrictEqual(
roles,
['user', 'assistant', 'user', 'assistant', 'user'],
`${protocol} 空消息未被过滤干净`
);
}
});
@@ -692,7 +801,11 @@ test('buildMessages 也接受历史参数', () => {
{ role: 'user', text: '旧问题' },
{ role: 'assistant', text: '旧回答' }
]);
assert.deepStrictEqual(msgs.map((m) => m.role), ['system', 'user', 'assistant', 'user']);
assert.strictEqual(msgs[1].content, '旧问题');
assert.match(msgs[3].content, /新问题/);
assert.deepStrictEqual(
msgs.map((m) => m.role),
['system', 'user', 'assistant', 'user', 'assistant', 'user']
);
assert.match(msgs[1].content, /正文/);
assert.strictEqual(msgs[3].content, '旧问题');
assert.match(msgs[5].content, /新问题/);
});