工具结果通常比用户提示词更容易耗尽模型上下文,尤其是日志、数据库查询和大文件内容。本文用 Node.js 实现一层结果处理器,根据数据类型执行分页、字段裁剪、摘要和引用保留。核心目标不是简单截断,而是让模型先看到可行动的概览,并能沿着引用回取原始证据。

为什么需要结果处理层

在智能体系统中,工具调用的返回值经常被直接拼接进下一轮提示词。这种实现很直观,但有三个问题。

第一,结果大小通常不可预测。一次数据库查询可能返回数万行,一段错误日志可能包含完整堆栈和大量重复请求信息,大文件则可能远超模型剩余上下文。即使没有触发硬性的 token 限制,过长的结果也会稀释真正重要的内容。

第二,模型容易在噪声中抓错重点。例如日志中最后一次异常未必是根因,数据库结果中的测试数据也可能干扰判断。如果只保留末尾若干字符,恰好可能把关键的初始化错误丢掉。

第三,直接截断会破坏证据链。模型的回答也许看起来合理,但用户无法知道结论来自哪一行、哪一条记录或哪个文件位置。

因此,工具适配层应该先把原始结果保存到可回取的存储,再向模型返回有限大小的视图。这个视图包含摘要、统计信息、少量代表性内容和稳定引用。模型需要细节时,再通过引用调用获取工具结果的一页。

数据类型首次返回内容继续获取方式
数据库行字段裁剪、总数、分页游标按页获取行
日志错误统计、时间范围、关键片段按行号或窗口获取
大文件文件元信息、分段摘要、关键行按字节或行号获取

统一的结果协议

结果处理器不必理解每个业务工具的全部语义,但需要输出稳定的协议。建议至少包含 kindsummaryitemstotalreferences。其中 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、异常类型和时间窗口做结构化聚合。摘要算法可以升级,但输出协议和引用机制不需要随之改变。

摘要与证据如何共存

摘要的职责是帮助模型决定下一步,而不是替代原始数据。一个可用的结果视图通常分成三层:

  1. 概览层:总行数、时间范围、错误数量、状态分布等低成本统计。
  2. 证据层:少量与问题最相关的记录、日志行或文件片段,并带有行号、主键或时间戳。
  3. 回取层:指向原始数据的引用,允许后续工具按范围读取。

摘要应明确区分事实和推断。例如“发现 12 条包含 timeout 的日志”是统计事实;“数据库连接池配置错误”则是推断,除非原始日志已经提供了明确证据。处理器可以返回 observations,但不要把未经验证的诊断写进 summary,否则模型很容易把摘要中的猜测当成工具事实。

对于大文件,可以先按固定行数分块,再为每个块保留首尾时间戳、匹配关键词的行和块引用。对于数据库,可以返回聚合统计和一页代表性数据,同时提供 next 引用。对于日志,最好优先保留异常发生前后的上下文,而不是机械保留最后 N 行。

在智能体中接入的边界

处理层放在工具执行器和模型消息组装之间,比较容易统一治理:

模型请求工具
  -> 工具执行器访问数据库、日志或文件
  -> 原始结果写入 ResultStore
  -> ResultLayer 生成受限视图
  -> 视图进入模型上下文
  -> 模型根据引用请求下一页或上下文窗口

需要重点处理四个边界。

其一是上下文预算。不要只设置一个固定字符数,因为不同模型的 token 化方式不同。工程上可以先按字符数设置保守上限,再在模型适配层统计实际 token;当预算不足时,优先减少样本数量,保留摘要和引用。

其二是权限。引用回取时必须重新校验调用者身份、租户和原始工具权限。不能因为模型拥有一个引用字符串,就允许它读取用户原本无权访问的数据。

其三是生命周期。引用应有过期时间和大小上限,避免 ResultStore 无限增长。对于审计要求较高的系统,还应记录谁在什么时候回取了哪一段原始结果。

其四是注入风险。日志和数据库字段都是不可信输入,可能包含“忽略之前指令”之类的文本。结果视图应把数据放在明确的数据区,工具协议也要告诉模型这些内容是待分析数据,而不是系统指令。

总结

结果处理层的价值不在于把输出简单截短,而在于改变工具结果进入上下文的方式:

  • 数据库结果使用分页和字段白名单,减少无关列与重复记录。
  • 日志结果先做统计、模式归并和相关窗口提取,保留错误附近的证据。
  • 大文件按范围分段,摘要只承担导航作用,原文通过引用回取。
  • 每个摘要都应绑定稳定定位信息,确保结论可以追溯和复核。
  • 引用回取必须重新执行权限校验,并设置过期、审计和容量控制。

这样设计后,模型面对的不是一堵未经处理的数据墙,而是一份可判断、可验证、可继续探索的工具结果视图。上下文预算得到控制,证据链也不会因为一次截断而消失。