跳到正文
astorlm
语言: 简体中文
← 地图

第 7 关

背包装满了

每一轮都要把整段历史记录重新发给模型。聊天持续得够久,它就装不下了。压缩会在那之前,先把最旧的部分缩小。
1/30 消息:
  • user
  • assistant
  • tool_result
一家自行车店的客服助手,运行在一个最多只能读 8,000 个 token 的模型上。这口井就是那个窗口。每条消息都作为一个方块落下,system prompt 是井底。红线在 80% 处。

EventBus

问题

模型一次能读的内容是有限的。这个上限就是它的上下文窗口,以 token 计(token 是词的片段,每个大约四个字符):许多小型本地模型是 8,000,大型托管模型则有几十万。一次请求里的所有东西都必须装得下:system prompt、到目前为止的每条消息、每个工具结果,还要给回复留出空间。

模型在两次调用之间什么都不记得,所以循环每一轮都要发送整段历史记录。一个既查订单又搜目录的客服聊天,会堆积起成千上万个模型早已用过的结果 token。每一轮都比上一轮更慢、更贵。总有一天,请求会装不下。

这就是 Gulp,上下文溢出。这时提供商会用一个错误拒绝请求;更糟的是,有些服务器会悄悄截掉最旧的部分来凑合。而最旧的部分,正是顾客说明自己指的是哪个订单的地方。

解决方案

每次调用模型之前,循环都会检查请求有多大。一旦超过某条线(这条线定在真实上限之下,好给回复留出空间),它就缩小历史记录。常见的做法有三种:

  • 截断旧的工具结果

    把模型已经用过的大块结果,换成一行注释加一小段预览。

    几乎不花钱,而工具结果通常是历史记录里最重的部分。如果模型又需要细节,它会再调用一次工具。

  • 丢弃旧的往来

    删掉最早的请求,连同其后直到下一个请求为止的所有内容。

    同样不花钱,但模型会彻底忘掉那一段对话。要保留最开头的那个请求,因为它往往说明了整个聊天的主题。

  • 让模型来总结

    把旧的部分发给模型一次,用它的总结替换掉那些消息。

    能保留意思,但要多花一次调用,而且总结可能悄悄漏掉那个唯一要紧的数字。

不管选哪种,规则都一样:从最旧的消息开始,别动最近的几个请求,一装得下就停手。astorlm 按顺序做前两种:先截断旧的工具结果,只有不够时才丢弃消息。

角色

还是那群熟悉的角色,这次换到了下落方块井里。

井 上下文窗口
一次请求能装下的全部内容。如果方块堆到顶,请求就装不下了。
方块 消息
每条消息一个,大小取决于它的 token 数。井底是 system prompt:每次请求都带着它,而且永远不会被压缩。
红线 阈值
窗口的 80%。一旦越过,循环会在调用模型之前先压缩。
锤子 压缩器
把最旧的工具结果压成一行注释,上面的一切随之落定。
KEEP keepRecentTurns
最近的两个请求,以及它们之后的一切。锤子永远不会碰它们。
SENT 账单
到目前为止所有调用累计发送的 token。对比一下锤子落下前后,它每一轮涨了多少。

在 EventBus 面板里,compact 这一行标记的是优化器在运行。astorlm 不会为此发出事件;它只会往你的 logger 里写一句 “Context optimized”。注意它出现的位置:在请求加入历史记录之后、turn_start 之前。

代码

使用 astorlm:压缩默认开启,大小根据提供商来定。传入 contextOptimizer 来设置真实的窗口、那条线,以及要保留多少个最近的请求。

从零手写:第 2 关的循环,加上一个跨请求保留的历史记录,并在每次调用模型之前调用 compact()。第 1 级截断旧的工具结果,第 2 级整段丢弃旧的往来。

import { OpenAIProvider, createLocalAgent, tool } from 'astorlm'
import { z } from 'zod'

const getOrder = tool({
  name: 'get_order',
  description: 'Everything about one order: items, shipping, invoice.',
  schema: z.object({ order: z.number().int() }),
  execute: async ({ order }) => loadOrder(order), // your code
})

const searchParts = tool({
  name: 'search_parts',
  description: 'Search the parts catalog, with stock and price for each match.',
  schema: z.object({ query: z.string() }),
  execute: async ({ query }) => searchCatalog(query), // your code
})

const createReturn = tool({
  name: 'create_return',
  description: 'Open a return for an order and ship a replacement part.',
  schema: z.object({ order: z.number().int(), part: z.string() }),
  execute: async ({ order, part }) => openReturn(order, part), // your code
})

const agent = await createLocalAgent({
  // Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
  provider: new OpenAIProvider({
    baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
    model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
    apiKey: 'YOUR_API_KEY', // local servers usually ignore it
  }),
  tools: [getOrder, searchParts, createReturn],
  maxTurns: 10,
  // Compaction is on by default, sized from the provider. OpenAIProvider assumes a
  // 128,000-token window, so on a small local model, say how big it really is.
  contextOptimizer: {
    maxTokens: 8000,
    compressThreshold: 0.8, // compact once the request passes 80% of the window
    keepRecentTurns: 2, // never touch the last two requests, or anything after them
  },
  // There's no event for compaction: astorlm logs "Context optimized…" when it happens.
  logger: console,
})

// One agent, one history: every run() adds to it, and the optimizer checks it before each model call.
await agent.run('Hi! My order #4471 came with a bent front wheel. Can you help?')
await agent.run('Is that same wheel in stock?')
const last = await agent.run('Great. Open a return for my order and ship me the new wheel.')
console.log(last.content)

注意事项

  • 告诉它真实的窗口大小。astorlm 的 OpenAIProvider 默认假设 128,000 个 token。在一个 8,000 token 的本地模型上,优化器会一直等一条模型永远到不了的线。
  • 永远不要把工具调用和它的结果拆开。如果某个工具结果对应的调用被丢掉了,大多数 API 会拒绝整个请求。要按整段往来丢弃,从一个请求到下一个请求。
  • 只有工具能再跑一次时,截断才安全。如果某个结果没法再取一次,比如付款收据,就把要紧的部分留在答案里,或者存到历史记录之外。
  • 告诉做总结的模型哪些东西必须保留。订单号、零件号、已做的决定。一份读起来很顺的总结,照样可能丢掉下一轮唯一需要的那个事实。
  • 压缩只计算你发送的内容。按每个 token 四个字符来估算,足以判断何时动手。记得在线下给回复留够空间。