Skip to content

第 7 课:加一个查询,再回看全局 ​

现在你已经能从输入追到真实写入。接下来给助手增加一种用途:按关键词查找便签。先让普通 Node 独立工作,能把业务查询本身与模型是否选择它分别验证。

用同一份 Notes 增加能力 ​

sh
node examples/notes/search.mjs

这个可运行入口创建材料,运行原助手,再用新的查询节点搜索同一份 Notes。预期能查到助手刚创建的「待办」,而原来的两张便签也仍然存在。查询不应追加或改写便签。

js
import assert from 'node:assert/strict';
import { Resource, ResourceOffer } from '@weiliang0121/capix-resources';
import { Node, Scene } from '@weiliang0121/capix-structure';
import { Resources, Runner } from '@weiliang0121/capix-runtime';
import { createNotesAssistant } from './assistant.mjs';
import { createMaterials } from './materials.mjs';
import { OfflineNotesProvider } from './offline-provider.mjs';

// 新操作复用原 Notes;不把查询结果复制到另一个内容所有者里。
const searching = new ResourceOffer({
  methods: {
    search(notes, query) {
      if (typeof query !== 'string' || !query.trim()) throw new TypeError('请输入非空关键词');
      const keyword = query.trim();
      return notes
        .keys()
        .map((id) => notes.read(id))
        .filter(({ value }) => value.title.includes(keyword) || value.text.includes(keyword));
    },
  },
});
const search = new Node({
  deps: { notes: searching.request() },
  run: (work) => work.resources.notes.search(work.input),
});

const materials = createMaterials();
const resources = new Resources().register(new Resource(materials.notes), searching);
const query = new Runner(new Scene(search), resources).build();
const assistant = createNotesAssistant(new OfflineNotesProvider(), materials.notes);
try {
  const before = await query.run('待办');
  assert.equal(before.length, 0);
  console.log(`助手运行前,搜索「待办」:${before.length} 条`);

  // 两个 Runner 共享内容实体,各自拥有自己的结构、申请与调用。
  await assistant.runner.run({ request: materials.request });
  const after = await query.run('待办');
  assert.equal(after.length, 1);
  assert.equal(after[0].value.title, '待办');
  assert.deepEqual(after[0], materials.notes.read(after[0].id));
  console.log(`助手运行后,搜索「待办」:${after.length} 条`);
  console.log(after[0].value.text);

  // 正文也参与查询;读取不修改实体,多个结果保留各自准确的身份。
  const matching = await query.run('演示');
  assert.equal(matching.length, 2);
  assert.ok(matching.some(({ id }) => id === materials.originals[0].id));
  assert.ok(matching.some(({ id }) => id === after[0].id));
  await assert.rejects(query.run('  '), /请输入非空关键词/u);
  assert.equal(materials.notes.keys().length, 3);
  for (const original of materials.originals) assert.deepEqual(materials.notes.read(original.id), original);
  assert.equal(query.isRunning, false);
  console.log('查询验证通过:同一实体、新的操作、独立调用;查询没有写入便签。');
} finally {
  await assistant.interactions.settled();
  assistant.interactions.close();
}

阅读时把代码对应到前面三课:新的 ResourceOffer 开放查询操作;Node 用 deps 申请它,并从本次输入取关键词;宿主把该 Offer 登记到已有 Notes,再建立 Scene 和 Runner 执行查询。它复用的是内容实体,不是把同一个结构实例挂到两棵树上。

为什么此时不先改提示?普通节点的运行结果可以直接核对。查询是否正确,与模型在一次对话中是否愿意查找,是两个不同问题。先独立验证前者,再把它加入模型选择范围,出问题时更容易定位。

从普通节点走到新工具 ​

如果要让模型使用查询,下一步应当在创建 Tool 时增加登记:选择工具名称,给出 description 和严格的输入 schema,把新 Node 与结果映射放入条目,同时供应它需要的资源。目录来自登记成员,提示里提到“搜索”不会自动产生工具。

当前离线 Provider 只实现两次读、一次写和最终回复,不会因为你登记新工具就自动改用搜索。要验证新的调用顺序,需要给它明确的新决策和反馈检查,或另写专门的离线验证;接入真实模型时,也仍要检查实际调用和结果。

练习:检验实体确实共享 ​

运行查询入口,对照源码中传给原助手和查询资源的 Notes 是否是同一个对象,再找出查询对新便签的断言。预期查询能够读取运行中新增的内容,查询前后集合大小不变。

这一点区别于再次调用 createMaterials():后者会产生新的集合和新的便签 ID,不能读到先前助手写入的材料。扩展一项能力时,先决定复用哪个内容所有者,再建立独立的操作和结构。

完整源码,回看接线 ​

下面保留完整文件,方便从头核对导入、参数与清理。逐章片段与这里的实现来自同一份源码。

js
import { z } from 'zod';
import { Resource, ResourceOffer } from '@weiliang0121/capix-resources';
import { Node, Scene } from '@weiliang0121/capix-structure';
import { Resources, Runner } from '@weiliang0121/capix-runtime';
import { Interactions } from '@weiliang0121/capix-interaction';
import { requireModelRequest, requireModelReply } from '@weiliang0121/capix-model-provider';
import { ModelExchanges, ModelExchangeResource } from '@weiliang0121/capix-model-exchanges';
import { Tool, ToolModelResource } from '@weiliang0121/capix-tool';

// 同一棵结构用于离线脚本和真实模型;业务实体由宿主提供。
export function createNotesAssistant(provider, notes, { onRead = () => {}, onWrite = () => {} } = {}) {
  // #region resources
  const reading = new ResourceOffer({
    methods: {
      read(notes, id) {
        const note = notes.read(id);
        onRead(note); // 仅在真实读取成功后输出宿主日志。
        return note;
      },
    },
  });
  const writing = new ResourceOffer({
    methods: {
      append(notes, value) {
        const note = notes.append(value);
        onWrite(note);
        return note;
      },
    },
  });
  // #endregion resources

  // #region tools
  // #region nodes
  const readNote = new Node({
    deps: { notes: reading.request() },
    run: (work) => work.resources.notes.read(work.input.id),
  });
  const writeNote = new Node({
    deps: { notes: writing.request() },
    run: (work) => work.resources.notes.append(work.input),
  });
  // #endregion nodes
  // #region tool-registration
  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 },
    },
  });
  // #endregion tool-registration
  // #endregion tools

  // #region model
  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();
  // #region assembly
  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 };
  // #endregion assembly
  // #endregion model
}
js
import { pathToFileURL } from 'node:url';
import { ChatCompletionsProvider } from '@weiliang0121/capix-model-provider';
import { createNotesAssistant } from './notes/assistant.mjs';
import { createMaterials } from './notes/materials.mjs';
import { OfflineNotesProvider } from './notes/offline-provider.mjs';

class ConfigurationError extends Error {}

export async function main(args = process.argv.slice(2)) {
  if (args.length > 1 || (args.length === 1 && !['--help', '--live'].includes(args[0]))) throw new ConfigurationError('参数只支持 --help 或 --live。');
  if (args[0] === '--help') {
    console.log('用法:node examples/notes-assistant.mjs [--help | --live]\n默认:离线固定决策脚本(不是智能模型),只使用内存便签。\n--live:真实模型调用;必须提供 MODEL_ENDPOINT、MODEL_NAME、MODEL_API_KEY。\n不读取 .env;不保存到磁盘,不设置 checkpoint。');
    return;
  }
  // #region 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();

  // #endregion provider

  const materials = createMaterials();
  console.log(live ? '真实模型模式;便签仅在内存中。' : '离线模式:固定决策脚本,不是智能模型;便签仅在内存中。');
  const events = [];
  // #region run
  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();
  }
  // #endregion run
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  try {
    await main();
  } catch (error) {
    // 真实网络/服务错误可能携带请求信息,真实模式不原样输出。
    console.error(process.argv.slice(2).includes('--live') && !(error instanceof ConfigurationError) ? '便签助手失败:真实模型或工具调用未完成。' : `便签助手失败:${error.message}`);
    process.exitCode = 1;
  }
}
js
import { Notes } from '../entities/domain.mjs';

// #region initial-values
export const initialValues = [
  { title: '会议记录', text: '整理用户反馈;发送演示链接' },
  { title: '项目计划', text: '检查安装步骤' },
];
// #endregion initial-values

// #region materials
export function createMaterials(values = initialValues) {
  const notes = new Notes(); // 仅内存,不落盘,不设置 checkpoint。
  const originals = values.map((value) => notes.append(value));
  const request = {
    messages: [
      { role: 'system', content: '你是便签助手。用 readNote 阅读用户提供的两张便签,整理出三项待办,用 writeNote 创建一张标题为“待办”的新便签。确认写入成功后回答新便签标题和完整内容。不要修改原便签,不要声称保存到磁盘。' },
      { role: 'user', content: JSON.stringify({ task: '读取这两张便签,生成待办便签并回答。', notes: originals.map(({ id, value }) => ({ id, title: value.title })) }) },
    ],
  };
  return { notes, originals, request };
}
// #endregion materials
js
// 固定决策脚本,不是智能模型。每一步检查真实反馈,再决定下一步。
// 与真实 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') };
}
js
import { randomUUID } from 'node:crypto';
import { stableValue } from '@weiliang0121/capix-contracts';

// 这是示例自己的业务对象,没有 Entity 基类,也没有隐含持久化。
export class Plan {
  id = randomUUID();
  #material;

  constructor(value) {
    if (!value.title || !Number.isSafeInteger(value.completed) || value.completed < 0) throw new TypeError('Invalid plan');
    this.#material = stableValue({ basis: { id: this.id, revision: 0 }, value });
  }

  read() {
    return this.#material;
  }

  revise(value, expected) {
    if (expected.id !== this.id || expected.revision !== this.#material.basis.revision) throw new Error('Plan has changed');
    if (!value.title || !Number.isSafeInteger(value.completed) || value.completed < 0) throw new TypeError('Invalid plan');
    // 固定下一版再替换指向。旧材料继续有效,但本例不保存可查询的历史版本。
    this.#material = stableValue({ basis: { id: this.id, revision: this.#material.basis.revision + 1 }, value });
    return this.#material;
  }
}

// 普通追加材料不需要消息 role、记忆 topic 或来源列表。
// #region notes
export class Notes {
  id = randomUUID();
  #revision = 0;
  #items = new Map();

  get version() {
    return String(this.#revision);
  }

  keys() {
    return [...this.#items.keys()];
  }

  read(key) {
    if (!this.#items.has(key)) throw new RangeError('Note does not exist');
    return this.#items.get(key);
  }

  append(value) {
    if (!value.title || !value.text) throw new TypeError('A note requires title and text');
    const item = stableValue({ id: randomUUID(), value });
    this.#items.set(item.id, item);
    this.#revision++;
    return item;
  }
}
// #endregion notes

domain.mjs 里的 Plan 属于其他实体示例,本教程只用 Notes。把完整项目连起来看:实体拥有内容,资源开放操作,Node 完成单步工作,Tool 把登记成员组成模型循环,宿主提供模型通道并负责运行与收尾。新增查询沿用这些已有职责,不需要把查询逻辑塞进 Provider。

接下来深入哪一层 ​

想把这些经验整理成可复用的判断方法,可以读核心概念:围绕同一个助手,分别理解实体、资源、结构、调用与交互,再决定观测和恢复如何加入。

想组合固定顺序或动态子任务,继续看结构与资源示例;想组织更多材料的读取范围,看实体与视图。需要解释运行过程时,可以从输出与记录接入观测。

希望失败后继续执行,则先读Checkpoint与恢复选择。本例没有配置 CP、Store 或持久化,也没有把 JavaScript 栈保存下来;正式恢复需要作者选择的 point 和完整依据。便签重启后仍存在,也需要业务实体自己的存储支持,不能单靠给 Runner 加一个开关实现。