Skip to content

06 · 实践:知识库研究助手 ​

本课交付:支持第二个主题的策略与测试。先运行本页基线,再完成 L6:替换策略,保留边界。最终作品按 结课项目验收。

本页保留最小同步基线,以下“未实现”均指本页示例。新增的异步执行与模型适配见 08,真实文档检索见 09;两个版本的状态与测试口径不同,请分别记录。

一、场景:这一步要解决什么问题 ​

前五章已经定义了一个研究助手的职责:查找授权笔记、读取证据、决定是否继续,并在结束前检查结果。现在把这些设计放进一个能运行、能观察、能故意弄错的最小程序。

先明确范围:本章交付的是离线执行器,不是上线的智能问答服务。它的脚本决策器按固定步骤运行,目的是让控制流程可复现;接入真实模型是后续独立步骤。

二、目标与方案设计 ​

本章目标:把接口、状态、权限和验证组合为可检查的教学系统。新增示例使用脚本化决策器,不调用模型;先观察执行控制,再明确接入模型时需要替换什么。资料核对:2026-09-06。

目标与完成条件 ​

让助手回答“延时任务有哪些方案,什么情况下选哪种”,只读取授权笔记,给出条件化比较、来源及证据不足之处。此章是本专题原创教学设计;前半部分提供控制循环伪代码,后半部分提供可运行的离线示例,没有部署在线 Agent,也不需要填写密钥阅读。

完成条件:回答与问题相关;每条主要判断有可追溯证据;无法证实的部分明确标注;任务状态是 completed,而不是仅仅生成了一段文字。

最小组件 ​

组件职责
请求入口获取用户目标与可信身份
控制循环调用模型、选择分支、限制预算
工具层搜索、读取、校验权限与参数
状态存储保存证据、工具结果、运行状态
验收器检查输出结构与证据覆盖

第一版只提供 search_notes 和 read_note 两个只读工具。不加入多 Agent、长期记忆和自动发布,先用最少组件测出哪里确实需要改进。

状态记录的关键字段 ​

text
task_id / user_id / goal / status
model_version / prompt_version
tool_calls:call_id、工具、参数、结果、状态
evidence:文档 ID、版本、片段、来源
open_questions / final_answer / stop_reason

状态存储要能区分“未开始、进行中、已成功、明确失败、结果未知”。这个区别对未来增加写工具尤其重要。

分三次实现 ​

  1. 固定流程基线:搜索一次、读取、生成;先跑正常与空结果用例。
  2. 增加决策循环:允许在证据不足时补充搜索,对比基线的收益和成本。
  3. 加入可靠性检查:覆盖权限、限流、循环和冲突证据,验证停止状态。

每一步使用 评估章节 的同一组用例。最后再判断是否需要多 Agent,而不是先增加角色数量。

后端开发者的对应关系 ​

Agent 编排可以理解为带模型决策的应用服务;工具是受控业务接口;状态记录类似任务表;检查点类似可恢复执行进度。模型的不确定性不会消除接口契约、身份隔离、幂等与可观测性的需要。

三、实现拆解 ​

可运行示例:先把执行闭环跑通 ​

提供两个可下载文件,放在同一目录:

使用支持内置测试运行器与 structuredClone 的 Node.js 运行。在本项目根目录执行:

bash
node docs/public/examples/research-agent.mjs
node --test docs/public/examples/research-agent.test.mjs

如果下载到其他目录,直接运行 node research-agent.mjs 和 node --test research-agent.test.mjs。

脚本不联网、不读写业务文件、不要求密钥。它使用内置教学数据,不会自动搜索本站文章。默认结果包含 completed、两条工具轨迹和一条已读取文档的引用。

阅读示例代码 ​

这里展示与下载文件相同的源码,避免教程和示例分别维护:

javascript
// Offline teaching harness: a scripted decision adapter, NOT an LLM agent.
import { pathToFileURL } from 'node:url';

export const NOTES = [
  { id: 'delay', owner: 'alice', revision: '1', title: '延时任务',
    text: '选择方案前,需要明确允许延迟、故障恢复和重复执行的业务影响。' },
  { id: 'private', owner: 'bob', revision: '1', title: '内部计划',
    text: 'Only Bob may read this fixture.' }
];

// Small, explicit contract checks for these two teaching tools, not a general Schema engine.
function hasExactKeys(value, keys) {
  return value !== null && typeof value === 'object' && !Array.isArray(value)
    && Object.keys(value).length === keys.length
    && keys.every(key => Object.hasOwn(value, key));
}

export function run(decide, { user = 'alice', notes = NOTES, maxSteps = 8 } = {}) {
  if (!Number.isInteger(maxSteps) || maxSteps < 1) throw new Error('Invalid step limit');
  const state = { status: 'running', evidence: [], trace: [], feedback: [] };
  const finish = (status, extra = {}) => ({ ...state, status, ...extra });
  const seenIds = new Set();

  for (let step = 0; step < maxSteps; step++) {
    // The adapter receives a copy; it cannot mutate trusted runtime state.
    let decision;
    try { decision = decide(structuredClone(state)); }
    catch { return finish('failed', { reason: 'adapter_error' }); }

    if (!decision || typeof decision !== 'object') {
      state.feedback.push('invalid_decision');
      continue;
    }
    if (decision.kind === 'ask_user' && typeof decision.question === 'string'
        && decision.question.trim()) {
      return finish('needs_input', { question: decision.question });
    }
    if (decision.kind === 'final') {
      const refs = decision.citations;
      const valid = typeof decision.answer === 'string' && decision.answer.trim()
        && Array.isArray(refs) && refs.length > 0
        && refs.every(ref => ref && typeof ref.id === 'string' && typeof ref.revision === 'string'
          && state.evidence.some(e => e.id === ref.id && e.revision === ref.revision));
      if (valid) return finish('completed', { answer: decision.answer, citations: refs });
      state.feedback.push('answer_requires_read_evidence');
      continue;
    }
    if (decision.kind === 'insufficient') {
      return finish('insufficient_evidence');
    }
    if (decision.kind !== 'tool' || typeof decision.callId !== 'string' || !decision.callId) {
      state.feedback.push('invalid_tool_request');
      continue;
    }
    if (seenIds.has(decision.callId)) {
      state.feedback.push('duplicate_call_id');
      continue;
    }
    seenIds.add(decision.callId);
    const { name, args, callId } = decision;
    if (!['search_notes', 'read_note'].includes(name)) {
      return finish('denied', { reason: 'tool_not_allowed' });
    }
    let result;
    if (name === 'search_notes') {
      if (!hasExactKeys(args, ['query', 'limit'])
          || typeof args.query !== 'string' || !args.query.trim()
          || !Number.isInteger(args.limit) || args.limit < 1 || args.limit > 5) {
        state.feedback.push('invalid_search_arguments');
        continue;
      }
      result = notes.filter(n => n.owner === user && n.title.includes(args.query))
        .slice(0, args.limit).map(({ id, revision, title }) => ({ id, revision, title }));
    } else {
      if (!hasExactKeys(args, ['id', 'revision'])
          || typeof args.id !== 'string' || !args.id.trim()
          || typeof args.revision !== 'string' || !args.revision.trim()) {
        state.feedback.push('invalid_read_arguments');
        continue;
      }
      // No disclosure of whether another user's document exists.
      const note = notes.find(n => n.id === args.id && n.owner === user);
      if (!note) return finish('denied', { reason: 'not_found_or_not_allowed' });
      if (note.revision !== args.revision) {
        state.feedback.push('stale_revision');
        continue;
      }
      const { owner, ...publicFields } = note;
      result = publicFields;
      if (!state.evidence.some(e => e.id === note.id && e.revision === note.revision)) {
        state.evidence.push(result);
      }
    }
    state.trace.push({ callId, name, result });
  }
  return finish('exhausted', { reason: 'step_limit' });
}

export function scriptedDecider(state) {
  if (state.trace.length === 0) return {
    kind: 'tool', callId: 'search-1', name: 'search_notes', args: { query: '延时任务', limit: 3 }
  };
  if (state.trace.length === 1) {
    const first = state.trace[0].result[0];
    return first ? { kind: 'tool', callId: 'read-1', name: 'read_note',
      args: { id: first.id, revision: first.revision } } : { kind: 'insufficient' };
  }
  const evidence = state.evidence[0];
  return { kind: 'final', answer: evidence.text,
    citations: [{ id: evidence.id, revision: evidence.revision }] };
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  console.log(JSON.stringify(run(scriptedDecider), null, 2));
}

重点观察三个设计:模型适配器只能看到状态副本;资源身份由运行时传入;最终引用必须指向已实际读取的文档版本。搜索命中并不会自动进入证据集合,需要完成读取动作。

脚本里的 scriptedDecider 只按阶段返回固定动作。它用于验证程序的控制边界,不具备理解问题、规划或判断证据的模型能力,也不能用这些测试成绩宣称 Agent 的任务成功率。

控制循环伪代码 ​

以下函数名都是抽象接口。真实实现需要接入模型、参数校验器、存储和工具执行器,并保留模型原始工具请求与对应调用 ID。

python
def run_agent(goal, trusted_user):
    state = new_state(goal, trusted_user)
    deadline = monotonic() + 60  # 教学预算

    for step in range(8):
        remaining = deadline - monotonic()
        if remaining <= 0:
            return stop(state, "budget_exhausted")

        try:
            decision = model_decide(
                context=select_context(state),
                tools=READ_ONLY_TOOLS,
                timeout=remaining,
            )
            validate_decision(decision)

            if decision.kind == "ask_user":
                return pause(state, decision.question)

            if decision.kind == "final":
                verdict = check_answer(decision.answer, state.evidence)
                if verdict.accepted:
                    return complete(state, decision.answer)
                record_feedback(state, verdict)
                continue

            # 此教学版本只接受每轮一个工具请求
            validate_tool_call(decision.call, READ_ONLY_TOOLS)
            authorize(trusted_user, decision.call)
            remaining = deadline - monotonic()
            if remaining <= 0:
                return stop(state, "budget_exhausted")
            result = execute_tool(
                decision.call,
                identity=trusted_user,
                timeout=min(5, remaining),
            )
            record_call_and_result(state, decision.call, result)
            checkpoint(state)

        except PermissionDenied:
            return stop(state, "permission_denied")
        except InvalidDecision as error:
            record_feedback(state, safe_error(error))
        except TransientError as error:
            # 继续循环也消耗轮数;正式实现应增加独立重试计数和退避
            record_feedback(state, safe_error(error))

    return stop(state, "step_limit")

check_answer 的结构检查可自动完成,引用是否真正支持结论仍需要语义评估。这段代码没有实现流式输出、多工具并发、完整异常分类或生产级恢复,不能直接复制当作完整服务。

源码应该从哪里开始读 ​

先读 scriptedDecider 的三条分支,再读 run,不用从第一行一直读到最后一行。这样可以先看到调用方期望什么,再检查执行器如何限制它。

代码位置关注的问题运行中应观察到什么
state.trace.length === 0第一条动作如何产生请求搜索,而不是直接生成结论
name === 'search_notes'搜索是否带权限过滤只返回当前用户可读的候选
read_note 对应分支什么时候成为证据所有者和版本检查通过后加入 evidence
decision.kind === 'final'如何验收引用引用必须匹配已读取 ID 与版本
循环结束处非法请求是否会无限运行返回 exhausted,保留反馈

默认执行结果的关键字段如下,省略了正文和其他字段:

json
{
  "status": "completed",
  "trace": [
    { "callId": "search-1", "name": "search_notes" },
    { "callId": "read-1", "name": "read_note" }
  ],
  "citations": [{ "id": "delay", "revision": "1" }]
}

这里实际上有三次决策、两次工具执行;最终回答不算工具调用。completed 在当前实现中只表示通过已实现的结构验收,不代表已经完成真实研究任务。默认回答只是教学笔记正文,还没有三种方案的完整比较。

按现象排查,而不是反复重跑 ​

现象先检查正确理解
找不到文件是否位于项目根目录,下载文件是否同目录这是启动路径问题,不是 Agent 决策问题
返回 insufficient_evidence搜索结果是否为空没有资料可以是正常结束
返回 denied工具是否在白名单,资源是否属于用户不应通过换用户参数绕过拒绝
返回 exhaustedfeedback 中的错误与调用 ID修正请求策略,不能只增加轮数
有引用但结论错误原文是否支持具体判断当前脚本不具备语义验收能力

四、验证与练习 ​

测试覆盖与一个刻意暴露的局限 ​

测试检查正常搜索读取、空结果、私有文档隔离、禁止工具、伪造引用、旧版本、无效参数、重复调用 ID、状态篡改、暂停与轮数上限。

2026-09-06 本地执行上述测试命令:17 项通过、0 项失败。新增用例覆盖额外字段、非对象参数、缺失字段和空白文档标识;这是离线执行器测试结果,不是模型评测分数。

最后一个测试特别重要:把最终答案换成与原文无关的句子,只要引用存在,结构验收仍可能通过。这个测试记录的是已知能力边界,而不是证明语义验收正确。接入真实模型后,需要使用 评估章 的语义评分与人工标注来补齐。

同理,本示例的白名单阻止任意工具执行,但没有真实模型,因此没有验证模型抵抗提示注入的能力。测试结果只适用于这里的确定性执行器。

扩展任务 ​

先把一个无关问题交给脚本决策器,观察它仍按固定步骤搜索“延时任务”。这能直接看到它与真实 Agent 的区别。然后用测试注入不同决策器,练习参数错误后的修正、明确资料不足的结束方式,以及用户补充信息后的重新运行。

完成确定性控制测试后,再引入真实模型评估。不要同时加入多 Agent、长期记忆、写工具和新检索器,否则一次失败很难判断来自哪项变化。

五、从教学示例走向真实应用 ​

如何接入真实模型 ​

保留 run 的职责边界,把决策器改造成模型适配器,并将同步循环改成可等待调用的异步控制器。适配器把提供方响应规范化为 tool、final、ask_user、insufficient 等内部事件。不要让提供方的 SDK 类型渗透到每个业务工具中。

接入时逐项处理:

  1. 工具 Schema 与提供方实际支持的字段一致。
  2. 原始模型工具请求与工具结果通过调用 ID 成对保存。
  3. 多工具请求有明确的顺序、并发和失败策略。
  4. 模型与工具调用均使用剩余任务时限,接入取消信号。
  5. 区分提供方错误、网络错误、业务错误与验收失败。
  6. 验收成功后再将结果交给用户或下一条工作流。

示例中的循环只有轮数限制,没有网络超时、Token 计费和持久化恢复。前文伪代码说明了总时限思路;生产实现还应检查模型返回后的剩余预算,且设置超时不等于远端操作必然取消。

从内存状态走向持久化 ​

在进程内保存 state 只能支持本次运行。需要跨进程恢复时,必须保存任务标识、已执行动作、证据版本、剩余工作和运行配置。LangGraph 使用检查点等机制持久化图执行状态;应按所用版本核对恢复行为。Persistence 文档。

本案例可以用以下事件模型讨论恢复:

text
TaskCreated → ToolRequested → ToolSucceeded → EvidenceAdded
                          ↘ ToolFailed
                          ↘ ToolOutcomeUnknown
AnswerProposed → AnswerRejected / TaskCompleted

如果进程在工具成功后、状态保存前退出,恢复时可能重放调用。只读操作通常影响成本与时效;写操作则可能重复产生结果。外部幂等键与业务对账需要单独设计,不能仅依赖任务存储。AWS 幂等 API。

给后端项目划分模块 ​

可以把模型适配、知识库访问和状态存储放在基础设施层,把“允许怎样的研究动作”和验收条件放在应用或领域逻辑中。控制器只负责接收请求、身份和任务状态查询。这里是本专题的模块划分建议,并非要求采用某个框架。

text
api/             提交任务、查询进度、取消
application/     研究循环、预算、验收协调
model-adapter/   模型请求与内部事件的转换
tools/           搜索和读取的授权、校验、执行
storage/         文档、任务、证据和轨迹
evaluation/      固定用例、评分规则、回归报告

六、接入验收与实验记录 ​

接入前的验收清单:避免一次改太多 ​

将真实模型接入视为独立里程碑,不需要把本站改成在线服务才能理解这套设计:

层次最小产物如何验证本站状态
工具执行边界搜索、读取、字段与所有者校验确定性单元测试已提供离线示例
模型适配响应转内部事件、调用 ID 关联桩响应契约测试 + 真实小样本设计,未接入
真正检索文章解析、片段版本与索引标注查询集 Recall@k设计,未实现
答案支持检查结论到证据的映射人工标注与评分器校准未实现语义验收
跨进程恢复持久状态与重复操作处理在动作前后注入退出故障设计,未实现

模型适配器测试至少覆盖:空响应、非法 JSON、未知工具、同一轮多个调用、结果 ID 错配、服务拒绝、流式参数尚未完整就断线。流式输出的半段参数不能提前执行;收到完整结构并完成校验后才能分派工具。接入哪个提供方,就按其当时文档实现适配,不把本章内部事件当成通用 API 字段。

把实验结果写回博客 ​

每次实验记录:问题、假设、固定条件、唯一改动、实际命令、结果、失败样本、不能证明的事情。比如“拒绝额外字段的测试通过”只证明对应校验分支,不证明系统具备真实身份认证。

当前示例继续保留“引用存在但语义无关也可能 completed”的反例。这个边界不应该靠给状态换名字掩盖;后续接入真正验收器时,再增加能拒绝该答案的测试并更新完成语义。

想把这些工程约束扩展到写操作与用户委托,可继续读安全与身份边界,以及SSO 与 Agent Identity 项目设计。后者是模拟项目,不是本站已上线能力。

七、本章小结 ​

到这里,你能运行一个搜索、读取、引用检查的闭环,也能解释它为什么停下来。保留前文列出的能力边界,再逐项接入模型、持久化和真实检索。优先验证每次新增能力的收益,不要把教学执行器包装成生产级 Agent。

八、参考资料 ​

返回 Agent 专题目录。本文场景与推演为原创教学设计,参考写法及资料边界见专题目录。