第 6 课:用失败检查理解边界
一次任务可能读成功、写成功,却没能把最终回答交给用户。只把执行结果分成“成功”和“失败”,不足以解释已经发生的业务变化。这一课用仓库现有验证,观察每个失败位置的真实结果。
先运行,再读断言
node examples/notes/verify.mjs预期最后打印「便签助手验证通过:读取、写入、回答、固定值、换素材及失败分支。」其中的失败是验证主动构造并断言的预期结果,不代表命令执行失败。
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
import { createNotesAssistant } from './assistant.mjs';
import { createMaterials } from './materials.mjs';
import { OfflineNotesProvider } from './offline-provider.mjs';
import { verifyNotesCli } from './verify-cli.mjs';
// 验证与离线替身独立于读者的 Node / Tool 定义。
export function verifyOfflineRun({ notes, originals }, provider, answer, events) {
assert.equal(provider.requests.length, 4);
assert.deepEqual(
events.map(({ kind }) => kind),
['read', 'read', 'write'],
);
assert.deepEqual(
events.slice(0, 2).map(({ note }) => note),
originals,
);
assert.equal(notes.keys().length, 3);
const created = events[2].note;
const expected = {
title: '待办',
text: originals
.flatMap(({ value }) =>
value.text
.split(/[;;]/u)
.map((task) => task.trim())
.filter(Boolean)
.map((task) => `- ${task}`),
)
.join('\n'),
};
assert.deepEqual(created.value, expected);
assert.deepEqual(notes.read(created.id), created);
assert.equal(answer, `已创建便签「待办」:\n${expected.text}`);
for (const original of originals) assert.deepEqual(notes.read(original.id), original);
// 追加另一张便签不会改变已经取得的材料;正式内容已固定。
const previous = structuredClone(created);
assert.throws(() => {
created.value.text = '篡改';
}, TypeError);
const candidate = { title: '额外材料', text: '原值' };
const extra = notes.append(candidate);
candidate.text = '变更后的输入';
assert.equal(extra.value.text, '原值');
assert.deepEqual(created, previous);
assert.equal(provider.requests[3].exchanges[2].feedback[0].content[0].text, JSON.stringify(previous));
}
async function runOffline(materials, provider = new OfflineNotesProvider()) {
const events = [];
const app = createNotesAssistant(provider, materials.notes, { onRead: (note) => events.push({ kind: 'read', note }), onWrite: (note) => events.push({ kind: 'write', note }) });
try {
const answer = await app.runner.run({ request: materials.request });
assert.equal(app.runner.isRunning, false);
return { answer, provider, events };
} finally {
await app.interactions.settled();
app.interactions.close();
}
}
export async function verifyNotesAssistant() {
const materials = createMaterials();
const first = await runOffline(materials);
verifyOfflineRun(materials, first.provider, first.answer, first.events);
const changed = createMaterials([
{ title: '采购', text: '购买电池' },
{ title: '准备', text: '打印清单;装好设备' },
]);
const second = await runOffline(changed);
verifyOfflineRun(changed, second.provider, second.answer, second.events);
assert.notEqual(first.answer, second.answer);
// 合法 UUID 但实体不存在:读取失败不得变成最终成功或触发写入。
const missing = createMaterials();
const user = JSON.parse(missing.request.messages[1].content);
user.notes[0].id = '00000000-0000-4000-8000-000000000000';
missing.request.messages[1].content = JSON.stringify(user);
await assert.rejects(runOffline(missing), /Note does not exist/u);
assert.equal(missing.notes.keys().length, 2);
// 参数不合法会收到明确的工具错误反馈;脚本必须拒绝继续伪造成功。
const invalid = createMaterials();
const invalidUser = JSON.parse(invalid.request.messages[1].content);
invalidUser.notes[0].id = 'not-a-uuid';
invalid.request.messages[1].content = JSON.stringify(invalidUser);
await assert.rejects(runOffline(invalid), /invalid or failed tool feedback/u);
assert.equal(invalid.notes.keys().length, 2);
// 写入错误沿调用真实失败;已成功读取也不足以证明创建完成。
const rejected = createMaterials();
rejected.notes.append = () => {
throw new Error('Example write rejected');
};
await assert.rejects(runOffline(rejected), /Example write rejected/u);
assert.equal(rejected.notes.keys().length, 2);
// 新便签已经写入后,最终回答发送失败不会回滚该业务事实,也不隐式重试。
const undelivered = createMaterials();
const script = new OfflineNotesProvider();
const attempts = [];
const writes = [];
const failingFinalReply = {
async complete(request) {
attempts.push(request);
if (attempts.length === 4) throw new Error('Final reply unavailable');
return script.complete(request);
},
};
const app = createNotesAssistant(failingFinalReply, undelivered.notes, { onWrite: (note) => writes.push(note) });
try {
await assert.rejects(app.runner.run({ request: undelivered.request }), /Final reply unavailable/u);
assert.equal(app.runner.isRunning, false);
} finally {
await app.interactions.settled();
assert.equal(app.interactions.active, false);
app.interactions.close();
}
assert.equal(attempts.length, 4);
assert.equal(script.requests.length, 3);
assert.equal(writes.length, 1);
assert.equal(undelivered.notes.keys().length, 3);
assert.deepEqual(writes[0].value, { title: '待办', text: '- 整理用户反馈\n- 发送演示链接\n- 检查安装步骤' });
assert.deepEqual(undelivered.notes.read(writes[0].id), writes[0]);
assert.deepEqual(JSON.parse(attempts[3].exchanges[2].feedback[0].content[0].text), writes[0]);
for (const original of undelivered.originals) assert.deepEqual(undelivered.notes.read(original.id), original);
await verifyNotesCli();
console.log('便签助手验证通过:读取、写入、回答、固定值、换素材及失败分支。');
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await verifyNotesAssistant();文件先验证两组素材的完整运行。verifyOfflineRun 同时检查请求次数、读取顺序、新便签内容和最终回答;它还核对原便签没有被改写,以及传入对象后来变化不会修改已追加材料。成功判断由真实数据支撑。
然后看四个反例:
| 注入的问题 | 失败发生在哪里 | 本例核对的结果 |
|---|---|---|
| 合法 UUID,但便签不存在 | Notes.read 查询实体 | 调用拒绝,便签仍为两张 |
ID 为 not-a-uuid | 工具参数校验 | 脚本拒绝失败反馈,没有写入 |
append 被替换为抛错操作 | 写入执行 | 调用拒绝,仍为两张 |
| 第四次 Provider 请求抛错 | 写入后,生成最终回答的模型请求 | 调用拒绝,新增便签仍存在 |
前两个尤其值得一起读。格式校验只能回答“这像不像一个 UUID”;实体查询才回答“这里有没有这张便签”。离线脚本也会检查工具反馈,不能收到错误后继续编造成功文本。
已写入,不会因为回答失败而消失
在 failingFinalReply 一段中,前三轮仍交给正常脚本;第四轮明确抛出 Final reply unavailable。此时测试断言整个 runner.run 失败,同时检查 writes.length === 1、Notes 有三张便签、新便签仍可读回。
这给出了具体的先后关系:append 已安装内容,写入反馈已进入下一轮请求,生成最终回答的第四次模型请求随后失败。调用终态不会自动回滚实体。对于文件或远端服务,也不能从“最终没有成功返回”推导“外部效果没有发生”;资源自身必须说明真实提交结果。
本例既没有隐式重试,也没有恢复点。若直接重新运行 CLI,它会创建另一份内存材料,并不是接着这次失败继续执行。自动重做写入是否安全,需要另行设计业务去重或恢复依据。
练习:用断言解释一次失败
打开同一验证文件,找到第四次请求抛错的位置,再找到它之后对便签数量、实际正文和第四轮反馈的断言。重新运行上面的命令,预期仍通过。试着按“已经发生什么、失败在哪里、留下什么”复述这一个案例。
终端读写日志是宿主回调;这里使用的是可执行断言。若要长期记录调用事实,应另外接入记录与观测示例,不能把 CLI 文本当作内核记录或流式事件。
接下来复用同一个 Notes 增加操作:第 7 课:加一个查询,再回看全局。