18 · 实战一:从资料检索到可替换的笔记服务¶
本章所有可执行代码固定 npm 0.2.0-rc.2,对应源码 639ed015397290b3745d163aafe02ffee4aa3f84。目标:根据自己的资料解释失败attempt是否进入模型历史,答案引用笔记ID。先运行,再逐项修改;完整文件在实验源码附录,下载入口见首页。
18.1 先跑最小入口:只检查运行时能收信¶
在实验目录完成 npm ci --ignore-scripts、npm run build 后,运行:
预期 requests:1、reason:"completed"、taskStatus:"runtime-only"。阶段0故意不回答知识问题:它只验证官方SDK能启动实际dsh、发送问题并接收结算事件。运行时完成和业务问题已回答是两个判定。
下面是可复制成 first.mjs 的最小宿主。它使用已经编译的实验支持模块;这些模块提供实际profile组合和本地HTTP服务,不替换Agent loop或SDK。
import { createNotesHarness } from './dist/composition.js'
import { startScriptedServer } from './dist/mock-server.js'
const server = await startScriptedServer([{ text: '收到问题。' }])
const owned = await createNotesHarness({
endpoint: server.endpoint, apiKey: 'local-fixture-only',
workspace: process.cwd(), notes: [],
})
try {
const result = await owned.harness.run('你好')
console.log(result.finalResponse)
} finally {
try { await owned.close() } finally { await server.close() }
}
运行 node first.mjs,应输出 收到问题。。理解的重点不是这句固定回答,而是拥有关系:宿主拥有SDK runtime与HTTP fixture,所以finally负责结束两者。接下来保持这层不动,把工具与资料加入任务。
18.2 搜索只返回候选,读取才取得证据¶
阶段1在memory provider中放入一条资料:
search_notes({query:"Session"}) 返回 {matches:[{id,preview}]},本检查点preview限制24个JavaScript字符;read_note({id:"session"})才返回 {id,text} 全文。这样第二次请求知道“哪条笔记可能相关”,第三次才有足够证据回答。若搜索就返回所有全文,后面的read步骤只是形式,无法教会读者工具如何增加信息。
应看到3次HTTP、2次工具结果、taskStatus:"answered-with-evidence"。打开 artifacts/checkpoint-1/requests.json:第2份body的call-1结果没有“失败 attempt 只留诊断”,第3份body的call-2结果才有。fixture必须在对应 tool_use_id 下找到预期结果才继续;错ID、错误结果或缺全文会返回400。这证明真实链路有传递证据,不能替代真实模型的理解与引用质量评估。
18.3 从直接读数组到三个角色¶
一开始,你可以把notes数组直接闭包捕获在工具中。现在要支持用户选择JSON、将来接数据库,就需要让数据来源独立变化。我们抽出 NoteStore,保留两个方法:
import { Context, Service } from '@deepseek-ai/cordis'
export interface Note { id: string; text: string }
export abstract class NoteStore extends Service {
constructor(ctx: Context) { super(ctx, 'courseNotes') }
abstract search(query: string): Promise<Note[]>
abstract read(id: string): Promise<Note | undefined>
}
declare module '@deepseek-ai/cordis' {
interface Context { courseNotes: NoteStore }
}
这是 src/note-service.ts 的契约部分。declaration merging只添加TypeScript类型,真正发布服务的是provider构造 SnapshotNoteStore。notes-tools.ts声明inject依赖后,通过 ctx.courseNotes访问数据。不要把“写了interface”和“运行时服务已可用”当成同一步。
图回答的是“换数据源时改哪一层”:阶段2只换左边,工具名称/参数/模型行为仍在右边。契约也包括语义:搜索为不区分大小写的包含匹配,读取未知ID返回undefined,数据是启动快照。未来换向量库后排序和分页变化要明确更新契约,不能只凭同名方法说完全兼容。
18.4 换provider,不改consumer¶
这一步把同样资料写入 artifacts/checkpoint-2/notes.json,composition把provider row从 memory-notes.js换成 json-notes.js,传入宿主选择的绝对路径。输出仍应是3次请求与相同答案。比较两步的 notes.patch.json,确认 course-note-tools保持同一实现。
两个provider共用validateNotes:最多100条、ID为1–64个小写字母/数字/连字符、文本最多8192个JavaScript字符、ID唯一。JSON provider还核对普通文件和1MiB上限,在激活前读完并验证,再发布不可变快照。工具调用此后不再读文件,所以文件路径从未成为模型参数。
这里选择快照,是为了让整轮问答使用稳定资料。成本是修改文件不会立即生效,需要重启provider;收益是可重复、易测试,也没有读取过程中不同版本混合的问题。若产品要求实时数据,provider接口要增加版本/取消/分页语义,不能偷偷在renderer里再次查库。
JSON文件在启动读取期间应由宿主保持稳定。前后size检查是输入规模校验,不是对持续增长文件的硬内存沙箱。模型只能选择opaque ID;启动者选择哪个文件,是另一层部署权力。
18.5 结构化value与展示content各做一件事¶
读取工具使用官方 defineTool:输入schema要求id为string,body再检查ID格式与存在性;output.schema声明 {id:string,text:string};render返回 [{type:'text',text:'[session] …'}]。完整工具代码见08章。
| 位置 | 本例数据 | 用途 |
|---|---|---|
| provider结果 | {id,text} |
业务契约 |
| validated canonical value | 冻结的 {id,text} |
程序化调用/PTC可直接访问字段 |
| rendered content | [session] …文本块 |
native模型与tool/result的可读反馈 |
| presentationMeta | 本例没有 | UI额外展示信息,不能作为模型证据 |
旧式把对象先JSON.stringify为string也能运行,但每个程序消费者都要再parse,输出schema也只检查“是字符串”。现在schema能检查每个字段,renderer又可以独立决定如何引用来源。搜索renderer仍用JSON文本显示matches,canonical value本身是对象。
亲手改错一项验证:缺id或id为42,在输入schema失败;id为../private,在领域规则失败;id为absent,provider返回空并生成tool error。可运行 node --test --test-name-pattern='schema拒绝|canonical output' test/contracts.test.mjs 查看这些边界。
18.6 给已存在的工具加一条真正政策¶
此时两个工具仍在schema中,数据源也相同;新增 denyReadIds:['session']。src/notes-policy.ts提供独立policy:
ctx.on('tools/pre-execute', async (exec, next) => {
const args = exec.arguments
if (exec.name === 'read_note' && typeof args === 'object' && args !== null
&& 'id' in args && typeof args.id === 'string' && denied.has(args.id)) {
return { kind: 'deny', reason: `Reading note ${args.id} is disabled by the host policy.` }
}
return next()
})
denied来自已经验证的宿主配置Set,完整imports/Config在该文件。匹配就直接deny,其他调用return next交给下游。预期call-2得到is_error,第三请求没有全文,fixture回答证据不足;turn仍completed,业务状态是 insufficient-evidence。completed说明这轮正常收尾,不能把拒绝后的说明当成知识问题已完成。
组件测试另外给provider.read加计数:政策生效时为0,卸载政策后为1并得到结构化value。这条证据比只看返回“denied”更强。对照post-execute block测试,body副作用已发生,说明控制应放在合适阶段。
这条deny政策只限制读取全文;搜索摘要仍可见。如果资料存在本身也敏感,应让provider.search按同一授权主体过滤,或同时限制搜索。不要把这个教学gate当成完整资料ACL。
18.7 卸载和重挂:为什么值得使用effect¶
consumer的工具与prompt随fiber撤销;provider消失会使依赖consumer停用;重挂provider恢复贡献。你不用维护一个永久工具数组或逐个通知消费者。代价是要理解inject和生命周期,故障排查不能只问“文件是否import成功”。03章顺着这个现象解释provide、Fiber和effect源码。
接下来保留工具/服务,进入19章给这轮问答加预算与可留存数据。到这里,你自己的扩展已经是完整definition/provider/consumer加政策,而不是改了Agent loop里的一个分支。