第 4 课:接起模型与工具循环
两个 Node 一次各做一件事:读取或新增。用户的任务却需要连续几步,并依据前一步结果决定下一步。Tool 预设把这些普通节点组织为模型可以选择的工具,推进请求、执行、反馈与再次请求。
从登记中产生选择范围
const result = { succeeded: (note) => ({ status: 'ok', content: [{ kind: 'text', text: JSON.stringify(note) }] }) };
const assistant = new Tool({
policyVersion: 'notes-assistant.v1',
waitMs: 65000, // Provider 默认超时 60 秒;局部等待结束不取消独立发送。
tools: {
readNote: { node: readNote, description: 'Read an existing note by its supplied id.', input: z.strictObject({ id: z.uuid() }), result },
writeNote: { node: writeNote, description: 'Append a new note without changing existing notes.', input: z.strictObject({ title: z.string().min(1), text: z.string().min(1) }), result },
},
});Tool 是普通 Group 的预设。它从登记成员得到工具目录,并把目录带到模型请求中。模型提出工具名称与参数后,框架按目录和输入契约处理,再调用相应 Node。写下一个工具名称,并不能让模型任意访问 Notes。
policyVersion 标识本例这份工具策略,waitMs 设定局部等待时间。它不是整个应用的业务截止时间,也不会让一切外部发送在超时瞬间消失;收尾会在下一课单独处理。
这里最关键的循环是“带着反馈再问”。模型可以选择读取已有便签,也可以选择追加新便签;执行权仍在已登记的节点及其资源操作中。最终文本只是回答,它不会绕过写节点自动保存内容。
用确定的四轮看清过程
图示源码 · 图内可滚动查看
sequenceDiagram participant T as Tool participant P as Provider(经 Interactions) participant N as Node / Notes T->>P: 任务、目录 P-->>T: readNote(会议记录 ID) T->>N: 执行读取 N-->>T: 会议记录材料 T->>P: 带上读取反馈 P-->>T: readNote(项目计划 ID) T->>N: 执行读取 N-->>T: 项目计划材料 T->>P: 带上两份读取反馈 P-->>T: writeNote(标题、正文) T->>N: 执行追加 N-->>T: 新便签材料 T->>P: 带上写入反馈 P-->>T: 最终文本
离线 Provider 的完整实现直接来自运行代码:
// 固定决策脚本,不是智能模型。每一步检查真实反馈,再决定下一步。
// 与真实 Provider 使用相同 ModelRequest / ModelReply 和 Tool 结构。
export class OfflineNotesProvider {
requests = [];
async complete(request) {
this.requests.push(request);
const sources = JSON.parse(request.messages.find((message) => message.role === 'user').content).notes;
if (!Array.isArray(sources) || sources.length !== 2) throw new Error('Offline script requires two source notes');
const exchanges = request.exchanges ?? [];
const read = [];
let written;
for (const exchange of exchanges) {
const call = exchange.reply.output[0];
const feedback = exchange.feedback[0];
if (exchange.reply.output.length !== 1 || exchange.feedback.length !== 1 || call.kind !== 'tool-call' || feedback.toolCallId !== call.id || feedback.status !== 'ok') throw new Error('Offline script received invalid or failed tool feedback');
if (feedback.content.length !== 1 || feedback.content[0].kind !== 'text') throw new Error('Offline script requires one JSON feedback');
const note = JSON.parse(feedback.content[0].text);
const args = JSON.parse(call.arguments.text);
if (call.name === 'readNote' && read.length < 2 && !written) {
if (args.id !== sources[read.length].id || note.id !== args.id || typeof note.value?.text !== 'string') throw new Error('Offline script read feedback mismatch');
read.push(note);
} else if (call.name === 'writeNote' && read.length === 2 && !written) {
const expected = deriveTodo(read);
if (args.title !== expected.title || args.text !== expected.text || note.value?.title !== expected.title || note.value?.text !== expected.text || typeof note.id !== 'string' || sources.some((source) => source.id === note.id)) throw new Error('Offline script write feedback mismatch');
written = note;
} else throw new Error('Offline script received unexpected tool call');
}
const output = written ? [{ kind: 'text', text: `已创建便签「${written.value.title}」:\n${written.value.text}` }] : [toolCall(read.length < 2 ? `read-${read.length + 1}` : 'write-todo', read.length < 2 ? 'readNote' : 'writeNote', read.length < 2 ? { id: sources[read.length].id } : deriveTodo(read))];
return { formatVersion: 1, id: `offline-${exchanges.length + 1}`, model: 'offline-fixed-script', output, termination: { kind: 'completed', reason: written ? 'stop' : 'tool_calls' }, usage: null, native: { format: 'chat-completions', version: 1 } };
}
}
function toolCall(id, name, args) {
return { kind: 'tool-call', id, name, arguments: { encoding: 'json-text', text: JSON.stringify(args) } };
}
function deriveTodo(notes) {
const tasks = notes.flatMap((note) =>
note.value.text
.split(/[;;]/u)
.map((item) => item.trim())
.filter(Boolean),
);
if (!tasks.length) throw new Error('Offline script found no tasks');
return { title: '待办', text: tasks.map((task) => `- ${task}`).join('\n') };
}沿 complete 阅读:它先检查之前每次交换中的调用与反馈,再从读到的正文拆分事项,最后验证写反馈并形成文本。它不是预先打印一个固定答案;换材料时答案会跟着改变。但它仍是固定决策脚本,只认识两份来源、这个读取顺序和这两个工具,不能据此推断它能理解任意任务。
ModelExchanges 保存循环所用的请求起点、回复和工具结果;Interactions 负责发送及反馈。Provider 只需按模型协议完成一次请求,不负责直接调度业务 Node。这使真实模型适配和业务执行可以分别替换。
练习:对照第四轮的依据
先运行成功路径与已有验证:
node examples/notes-assistant.mjs
node examples/notes/verify.mjs在 Provider 中找到 written 分支,再在验证文件中找到 provider.requests.length 为 4 的断言。预期验证通过;第四轮请求已经带有写入反馈,最终文本使用该反馈中的新便签内容。
这样可以区分两个问题:模型“打算写什么”,与“节点实际写成了什么”。本例在离线模式中严格核对这条链路;换成真实模型后,选择和措辞可能改变,业务仍应以真实操作结果为依据。
下一课把模型通道与 Runner 接起来:第 5 课:装配、运行与收尾。需要研究原生协议和工具推进规则时,再读工具接入。