工具结果通常比用户提示词更容易耗尽模型上下文,尤其是日志、数据库查询和大文件内容。本文用 Node.js 实现一层结果处理器,根据数据类型执行分页、字段裁剪、摘要和引用保留。核心目标不是简单截断,而是让模型先看到可行动的概览,并能沿着引用回取原始证据。
为什么需要结果处理层
在智能体系统中,工具调用的返回值经常被直接拼接进下一轮提示词。这种实现很直观,但有三个问题。
第一,结果大小通常不可预测。一次数据库查询可能返回数万行,一段错误日志可能包含完整堆栈和大量重复请求信息,大文件则可能远超模型剩余上下文。即使没有触发硬性的 token 限制,过长的结果也会稀释真正重要的内容。
第二,模型容易在噪声中抓错重点。例如日志中最后一次异常未必是根因,数据库结果中的测试数据也可能干扰判断。如果只保留末尾若干字符,恰好可能把关键的初始化错误丢掉。
第三,直接截断会破坏证据链。模型的回答也许看起来合理,但用户无法知道结论来自哪一行、哪一条记录或哪个文件位置。
因此,工具适配层应该先把原始结果保存到可回取的存储,再向模型返回有限大小的视图。这个视图包含摘要、统计信息、少量代表性内容和稳定引用。模型需要细节时,再通过引用调用获取工具结果的一页。
| 数据类型 | 首次返回内容 | 继续获取方式 |
|---|---|---|
| 数据库行 | 字段裁剪、总数、分页游标 | 按页获取行 |
| 日志 | 错误统计、时间范围、关键片段 | 按行号或窗口获取 |
| 大文件 | 文件元信息、分段摘要、关键行 | 按字节或行号获取 |
统一的结果协议
结果处理器不必理解每个业务工具的全部语义,但需要输出稳定的协议。建议至少包含 kind、summary、items、total 和 references。其中 references 不应该只是一个自然语言描述,而应包含能被后续工具识别的定位信息。
引用可以设计成如下形式:
ref://tool-result/7f3a/database?page=2
ref://tool-result/7f3a/logs?startLine=420&endLine=470
实际系统中,引用的标识符应当随机生成或使用不可预测的 ID,并绑定租户、用户、工具调用和过期时间。示例为了便于运行使用内存 Map,生产环境可以替换为 Redis、对象存储或数据库。重要的是,模型拿到的是引用元数据,而不是直接拿到一个无法验证的摘要。
字段裁剪也应该显式配置。默认不要把所有列都传给模型,可以保留主键、状态、时间戳以及诊断所需字段。裁剪不是安全边界,敏感字段仍应在工具查询或授权层过滤,不能只依赖展示层隐藏。
用 Node.js 实现处理器
下面的脚本只依赖 Node.js 内置 API,保存为 result-layer.js 后可直接运行。它演示了数据库分页、日志摘要、引用生成和引用回取四个动作。
const crypto = require("node:crypto");
class ResultStore {
constructor() {
this.data = new Map();
}
save(value) {
const id = crypto.randomUUID();
this.data.set(id, value);
return id;
}
load(id) {
return this.data.get(id);
}
}
function makeReference(id, kind, query) {
const params = new URLSearchParams(query);
return `ref://tool-result/${id}/${kind}?${params.toString()}`;
}
function paginateRows(rows, options, store) {
const page = Math.max(1, Number(options.page || 1));
const pageSize = Math.min(100, Math.max(1, Number(options.pageSize || 20)));
const fields = options.fields || Object.keys(rows[0] || {});
const start = (page - 1) * pageSize;
const selected = rows.slice(start, start + pageSize).map((row) => {
return Object.fromEntries(fields.filter((field) => field in row).map((field) => [field, row[field]]));
});
const id = store.save(rows);
const hasMore = start + selected.length < rows.length;
return {
kind: "database",
summary: `返回第 ${page} 页,共 ${rows.length} 条记录。`,
total: rows.length,
page,
pageSize,
items: selected,
references: {
current: makeReference(id, "database", `page=${page}&pageSize=${pageSize}`),
next: hasMore ? makeReference(id, "database", `page=${page + 1}&pageSize=${pageSize}`) : null
}
};
}
function summarizeLogs(text, options, store) {
const lines = text.split(/\\r?\\n/);
const errorLines = lines
.map((line, index) => ({ line, lineNumber: index + 1 }))
.filter(({ line }) => /\\b(error|fatal|exception|timeout)\\b/i.test(line));
const counts = new Map();
for (const item of errorLines) {
const key = item.line.replace(/\\d+/g, "<n>").slice(0, 120);
counts.set(key, (counts.get(key) || 0) + 1);
}
const repeated = [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([message, count]) => ({ message, count }));
const id = store.save(lines);
const important = errorLines.slice(-10);
return {
kind: "logs",
summary: `共 ${lines.length} 行,发现 ${errorLines.length} 行疑似异常,${repeated.length} 类重复模式。`,
patterns: repeated,
samples: important,
references: {
all: makeReference(id, "logs", "startLine=1&endLine=" + lines.length),
aroundLastError: important.length
? makeReference(id, "logs", `startLine=${Math.max(1, important[0].lineNumber - 3)}&endLine=${Math.min(lines.length, important[important.length - 1].lineNumber + 3)}`)
: null
}
};
}
function fetchReference(reference, store) {
const url = new URL(reference.replace("ref://", "https://"));
const parts = url.pathname.split("/");
const id = parts[2];
const value = store.load(id);
if (!value) throw new Error("引用不存在或已经过期");
const query = Object.fromEntries(url.searchParams);
if (parts[3] === "database") {
const page = Math.max(1, Number(query.page || 1));
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize || 20)));
return value.slice((page - 1) * pageSize, page * pageSize);
}
const start = Math.max(1, Number(query.startLine || 1));
const end = Math.min(value.length, Number(query.endLine || start));
return value.slice(start - 1, end).map((line, index) => ({ lineNumber: start + index, line }));
}
const store = new ResultStore();
const rows = Array.from({ length: 45 }, (_, index) => ({
id: index + 1,
status: index % 7 === 0 ? "failed" : "ok",
email: `user${index + 1}@example.com`,
createdAt: "2025-01-01T00:00:00.000Z",
internalNote: "不要传给模型的字段"
}));
const logs = [
"2025-01-01T10:00:00Z INFO server started",
"2025-01-01T10:01:00Z ERROR database timeout after 3000ms",
"2025-01-01T10:01:01Z ERROR database timeout after 3000ms",
"2025-01-01T10:02:00Z INFO retry succeeded"
].join("\\n");
const databaseView = paginateRows(rows, { page: 1, pageSize: 3, fields: ["id", "status", "createdAt"] }, store);
const logView = summarizeLogs(logs, {}, store);
console.log(JSON.stringify({ databaseView, logView }, null, 2));
console.log("回取结果:", fetchReference(logView.references.aroundLastError, store));
这个实现有意把分页参数限制在 1 到 100 之间,避免调用方通过异常大的 pageSize 绕过结果控制。日志摘要使用简单的正则和归一化规则,只适合演示;真实系统应按日志级别、请求 ID、异常类型和时间窗口做结构化聚合。摘要算法可以升级,但输出协议和引用机制不需要随之改变。
摘要与证据如何共存
摘要的职责是帮助模型决定下一步,而不是替代原始数据。一个可用的结果视图通常分成三层:
- 概览层:总行数、时间范围、错误数量、状态分布等低成本统计。
- 证据层:少量与问题最相关的记录、日志行或文件片段,并带有行号、主键或时间戳。
- 回取层:指向原始数据的引用,允许后续工具按范围读取。
摘要应明确区分事实和推断。例如“发现 12 条包含 timeout 的日志”是统计事实;“数据库连接池配置错误”则是推断,除非原始日志已经提供了明确证据。处理器可以返回 observations,但不要把未经验证的诊断写进 summary,否则模型很容易把摘要中的猜测当成工具事实。
对于大文件,可以先按固定行数分块,再为每个块保留首尾时间戳、匹配关键词的行和块引用。对于数据库,可以返回聚合统计和一页代表性数据,同时提供 next 引用。对于日志,最好优先保留异常发生前后的上下文,而不是机械保留最后 N 行。
在智能体中接入的边界
处理层放在工具执行器和模型消息组装之间,比较容易统一治理:
模型请求工具
-> 工具执行器访问数据库、日志或文件
-> 原始结果写入 ResultStore
-> ResultLayer 生成受限视图
-> 视图进入模型上下文
-> 模型根据引用请求下一页或上下文窗口
需要重点处理四个边界。
其一是上下文预算。不要只设置一个固定字符数,因为不同模型的 token 化方式不同。工程上可以先按字符数设置保守上限,再在模型适配层统计实际 token;当预算不足时,优先减少样本数量,保留摘要和引用。
其二是权限。引用回取时必须重新校验调用者身份、租户和原始工具权限。不能因为模型拥有一个引用字符串,就允许它读取用户原本无权访问的数据。
其三是生命周期。引用应有过期时间和大小上限,避免 ResultStore 无限增长。对于审计要求较高的系统,还应记录谁在什么时候回取了哪一段原始结果。
其四是注入风险。日志和数据库字段都是不可信输入,可能包含“忽略之前指令”之类的文本。结果视图应把数据放在明确的数据区,工具协议也要告诉模型这些内容是待分析数据,而不是系统指令。
总结
结果处理层的价值不在于把输出简单截短,而在于改变工具结果进入上下文的方式:
- 数据库结果使用分页和字段白名单,减少无关列与重复记录。
- 日志结果先做统计、模式归并和相关窗口提取,保留错误附近的证据。
- 大文件按范围分段,摘要只承担导航作用,原文通过引用回取。
- 每个摘要都应绑定稳定定位信息,确保结论可以追溯和复核。
- 引用回取必须重新执行权限校验,并设置过期、审计和容量控制。
这样设计后,模型面对的不是一堵未经处理的数据墙,而是一份可判断、可验证、可继续探索的工具结果视图。上下文预算得到控制,证据链也不会因为一次截断而消失。
评论