第 5 课:装配、运行与收尾
内容、能力和节点都准备好了,现在由宿主把它们接成可运行的一棵结构。createNotesAssistant 接收 Provider 和 Notes,而不是自己偷偷选择模型或创建另一份数据。这让同一套业务既能离线验证,也能显式接入外部服务。
接起两种模型职责
const contract = { key: 'example.notes.model.v1', request: requireModelRequest, reply: requireModelReply };
const interactions = new Interactions().register(contract, { send: (request) => provider.complete(request) });
const exchanges = new ModelExchanges();
const resources = new Resources()
.register(new Resource(notes), reading)
.register(new Resource(notes), writing)
.register(new Resource(exchanges), ModelExchangeResource)
.register(new Resource({ exchanges, channel: interactions.bind(contract), ...(provider.inspectRequest ? { inspection: provider } : {}) }), ToolModelResource);
const runner = new Runner(new Scene(assistant), resources, interactions).build();
return { runner, interactions };contract 声明此通道的请求与回复校验。Interactions.register 把发送动作接到 provider.complete,bind 产生给 Tool 使用的通道。另一边,new ModelExchanges() 创建本次宿主持有的交换实体,用来保存模型循环材料。
资源登记把两者接给 Tool:ModelExchangeResource 提供交换操作,ToolModelResource 把交换实体、发送通道和可选请求检查能力组合起来。这样 Tool 能推进模型工作,而不必自己持有某个具体服务的 Key。
最后,new Scene(assistant) 建立根结构,Runner 接收结构、资源和交互宿主。build() 固定配置和接线,此时还未执行读取或发送。工厂返回 Runner 与 Interactions,是因为宿主既要发起调用,也要照管独立发送的生命周期。
发起一次任务,等待退出
const { runner, interactions } = createNotesAssistant(provider, materials.notes, {
onRead(note) {
events.push({ kind: 'read', note });
console.log(`readNote 成功:${note.value.title}\n${note.value.text}`);
},
onWrite(note) {
events.push({ kind: 'write', note });
console.log(`writeNote 成功:${note.value.title}\n${note.value.text}`);
},
});
try {
const answer = await runner.run({ request: materials.request });
const readIds = events.filter(({ kind }) => kind === 'read').map(({ note }) => note.id);
if (materials.originals.some(({ id }) => !readIds.includes(id)) || events.filter(({ kind }) => kind === 'write').length !== 1) throw new Error('便签助手没有读完两张原便签并完成一次写入。');
console.log(`最终回答:\n${answer}`);
console.log('本次新增便签:');
for (const { note } of events.filter(({ kind }) => kind === 'write')) console.log(JSON.stringify(note, null, 2));
} finally {
// CLI 只有这一次入口,不再发起新调用;等待宿主持有的独立发送退出再关闭。
await interactions.settled();
interactions.close();
}runner.run({ request }) 才开始这一次任务。两个回调记录真实操作,末尾检查读过两张原便签且只写入一次,再输出最终回答。检查发生在执行之后,不能充当写入之前的业务门禁。
注意 finally:Runner 会等待本次已经启动的子调用退出并完成接纳,但 Interaction 与调用独立存活。局部等待结束并不取消独立发送。因此这个只有一次入口的 CLI 停止发起新调用后,还会 await interactions.settled(),再 close()。关闭通道不是提前中止网络请求的替代品。
在入口选择 Provider
const live = args[0] === '--live';
let provider;
if (live) {
const endpoint = process.env.MODEL_ENDPOINT;
const model = process.env.MODEL_NAME;
const apiKey = process.env.MODEL_API_KEY;
if (![endpoint, model, apiKey].every((value) => typeof value === 'string' && value.trim())) throw new ConfigurationError('--live 必须提供 MODEL_ENDPOINT、MODEL_NAME、MODEL_API_KEY。');
// 配置错误不回显 endpoint、模型名或密钥;本入口不读取环境文件。
try {
provider = new ChatCompletionsProvider({ endpoint, model, apiKey });
} catch {
throw new ConfigurationError('真实模型配置无效,请检查 MODEL_ENDPOINT、MODEL_NAME、MODEL_API_KEY。');
}
} else provider = new OfflineNotesProvider();默认分支实例化离线脚本。--live 分支要求显式提供三个配置,然后创建 Chat Completions Provider;它只读当前进程环境,不读取 .env。
export MODEL_ENDPOINT='https://your-provider.example/v1/chat/completions'
export MODEL_NAME='your-model'
export MODEL_API_KEY='your-key'
node examples/notes-assistant.mjs --live这条命令会调用真实服务,需要替换为你实际使用的服务配置;密钥留在终端环境中。业务实体和节点接线都不变,但真实模型的调用次数、选择和回答可能不同。不要用默认四轮脚本的断言承诺真实模型行为。
练习:验证入口选择
node examples/notes-assistant.mjs --help
node examples/notes-assistant.mjs第一条只打印使用说明并退出,不创建材料或调用模型;第二条走默认离线链路。预期看到正常答案,进程完成退出。再沿入口源码找出 Provider 选择、createMaterials、工厂调用、run 与 finally,你就能说明哪些工作属于宿主装配,哪些发生在单次调用内。
成功路径已完整,下一步看失败留下什么:第 6 课:用失败检查理解边界。独立发送与局部等待的细节见交互契约。