跳到主要内容

"The prompt is too long" / 模型上下文长度超限

您所看到的现象

类似于以下的报错信息:

  • The prompt is too long: 207601, model maximum context length: 202751
  • This model's maximum context length is 128000 tokens. However, your messages resulted in …
  • Input is too long for the model
  • context length exceeded

这些错误来自模型提供商(如 OpenAI、Anthropic、Google、您的 Ollama 服务器、GLM-4/5.x 等),而不是来自 Open WebUI。提供商计算了您发送的所有内容的 Token 数量,并由于超过了模型的上下文窗口而拒绝了该请求。

发生此情况的原因

模型所看到的 "Prompt" 实际上是整个对话的内容——而不仅仅是您刚刚输入的那条消息。每当您发送一条新消息时,Open WebUI 都会转发:

  • 您的系统 Prompt(System Prompt)
  • 完整的对话历史(该对话中先前每一次的用户/助手交替发言)
  • 任何直接内联到上下文中的附加文件(而非通过 RAG 检索到的文件)
  • 任何工具定义(Tool Definitions)和先前的工具调用结果(Tool Call Results)
  • 任何通过 Inlet 注入的上下文(来自过滤器、RAG、网页搜索、记忆等)
  • 您最新的用户消息

随着对话的进行,历史记录不断增加。大型附件或过长的工具调用输出可能会在单轮对话中就吞噬掉整个上下文窗口。一旦所有这些内容的总和超过了模型的上下文窗口,提供商就会拒绝该请求。

为什么 Open WebUI 不为您自动截断

Open WebUI 有意没有内置一个通用的上下文剪裁器。这是一个设计抉择,而非疏忽,且这一设定基本不会改变。原因如下:

  1. 每个模型都使用不同的分词器(Tokenizer)。 相同文本在 OpenAI (tiktoken)、Anthropic、Gemini、GLM、Llama 系列、Mistral、Qwen 等模型之间的 Token 数量是不同的。一个真正正确的剪裁器需要为现存的每个提供商配备针对特定模型的分词器。一旦在这个环节出错,就会导致隐蔽的数据损坏。
  2. 每个模型都有不同的上下文窗口。 8k、32k、128k、200k、1M——而且在计算时,您还需要考虑预留的输出 Token、提供商侧的开销以及多模态内容。
  3. 每个人都期望不同的截断策略。 我们看到过用户提出以下所有诉求,且每一项都是合理的:
    • Token 数量剪裁。
    • 消息条数剪裁。
    • 按 **对话轮数(Turns)**剪裁。
    • 仅剪裁非系统、非助手消息。
    • 优先剪裁文件附件,保留对话内容。
    • 优先剪裁工具调用结果,保留其他所有内容。
    • 针对对话长度设置硬上限(超过 N 轮后阻止进一步发送消息)。
    • 对较旧的消息进行总结(Summarize)而不是直接丢弃,并用摘要替换掉被丢弃的块。
    • 按模型制定策略(Gemini 保留 1M 个 Token,GPT-4 保留 128k,较小的本地模型保留 32k)。

没有任何单一的策略能适用于每一种部署方案、每一个用户和每一个模型。内置的实现从定义上来说对大多数用户而言都是不妥协的,而且会掩盖更好的解决方案:给用户提供钩子(Hook)并让他们自己做选择。

支持的解决方式:使用过滤器 Function

Open WebUI 中的上下文管理是通过 过滤器 Function(filter Functions) 来实现的。inlet() 在请求被发送到模型之前会在每次请求时运行——它会接收完整的 body(包括 body["messages"])并可以对其进行自由修改。这就是您要使用的钩子。

常见的处理方法,按复杂程度递增排序:

  1. 硬性对话长度上限。 如果 len(body["messages"]) > N 则拒绝或报错。简单且可预测;无需分词。
  2. 最新 N 轮窗口。 保留系统 Prompt 以及最靠近当前的 N 轮用户/助手对话,丢弃更早的对话。
  3. 按模型设置 Token 预算窗口。 估算每条消息的 Token 数量(例如,对 OpenAI 系列使用 tiktoken,对其他模型使用字符数/4 的启发式估算),并从最早的非系统消息开始剪裁,直到总量契合模型的窗口。
  4. 总结并替换(Summarize-and-replace)。 当窗口即将溢出时,调用一个低成本模型来总结最旧的一块消息,然后用一条由助手编写的摘要消息替换掉该块。在不破坏窗口的前提下保留长期的上下文。
  5. 优先剪裁附件或工具输出。 在处理对话本身之前,先从旧的对话轮次中剥离大型文件内容或工具结果。

Open WebUI 社区网站上已经存在实现大部分此类功能的社区过滤器。安装一个,配置它的阀门(Valves),大功告成。如果没有完全契合您策略的,可以将最接近的一个复制到 Functions 管理页面中并进行编辑——过滤器是纯 Python 编写的,非常易于调整。

极简示例:“最新 N 轮”过滤器

显示完整的过滤器代码(保留最后 N 条非系统消息)
from pydantic import BaseModel, Field


class Filter:
    class Valves(BaseModel):
        priority: int = Field(
            default=0,
            description="在依赖于最终消息列表的其他过滤器之前运行。",
        )
        max_turns: int = Field(
            default=20,
            description="要保留的非系统消息的最大数量(更旧的将被丢弃)。",
        )

    def __init__(self):
        self.valves = self.Valves()

    async def inlet(self, body: dict) -> dict:
        messages = body.get("messages", [])
        if not messages:
            return body

        system_msgs = [m for m in messages if m.get("role") == "system"]
        other_msgs = [m for m in messages if m.get("role") != "system"]

        if len(other_msgs) > self.valves.max_turns:
            other_msgs = other_msgs[-self.valves.max_turns :]

            # 工具调用修复:切片后,新位于头部的消息
            # 可能是孤立的工具调用结果,或者是其 tool_calls 引用了已被丢弃的 tool 消息的 assistant 消息。
            # 提供商(OpenAI / Anthropic / ...)会在遇到这些情况时返回 400 错误——因此需要进行修剪,
            # 直到窗口起始于提供商能够接受的内容。
            while other_msgs and other_msgs[0].get("role") == "tool":
                other_msgs.pop(0)

            if (
                other_msgs
                and other_msgs[0].get("role") == "assistant"
                and other_msgs[0].get("tool_calls")
            ):
                expected = {tc.get("id") for tc in other_msgs[0]["tool_calls"]}
                seen = {
                    m.get("tool_call_id")
                    for m in other_msgs[1:]
                    if m.get("role") == "tool"
                }
                if not expected.issubset(seen):
                    other_msgs.pop(0)

        body["messages"] = system_msgs + other_msgs
        return body

Admin Panel(管理员面板) → Functions(函数) 中全局启用此过滤器,或将其关联到特定模型。每个模型的 max_turns 阀门都可以通过模型卡片进行配置,因此您可以为本地 8k 模型设置一个较小的窗口,而为 Gemini 1M 设置一个较大的窗口。

为什么要有工具调用修复(tool-call repair)块?

启用工具调用后,调用工具的 assistant 消息会与携带结果且共享相同 tool_call_id 的一条或多条 tool 消息配对。如果 max_turns 恰好在该配对中间切分了对话——保留了孤立的另一半——上游提供商会返回 400 错误,因为工具调用/结果的结构无效。修复块会丢弃这些孤立消息,使窗口始终从一个干净的边界开始。这与生产级社区过滤器进行上下文管理时所做的一致;过滤器的其余部分则是常规的剪裁逻辑。

稍微复杂一些:按模型设置 Token 预算

计算轮数很容易理解,但在实践中不够精确——40 轮简短的对话可以轻松塞进 8k Token 中,而 5 轮带有 200 页 PDF 附件的对话却不行。更为实用的策略是:“保留所有内容,直到即将突破模型的上下文窗口,然后丢弃最旧的非系统消息,直到能够容纳为止。”

这第二个示例实现了这一点。它:

  • 根据字符长度估算 Token 数量(低成本的启发式估算,无外部依赖;如果需要严格计算,可换成 tiktoken 或真实的分词器)。
  • 从阀门中读取每个模型的预算,这样过滤器的单个实例就可以同时为您的 8k 本地模型和 1M 的 Gemini 工作。
  • 为响应预留了可配置的余量(Headroom)。
  • 在剪裁后重新应用第一个示例中的工具调用修复。
显示完整的过滤器代码(按模型的 Token 预算剪裁器)
import json
from pydantic import BaseModel, Field


class Filter:
    class Valves(BaseModel):
        priority: int = Field(
            default=0,
            description="在依赖于最终消息列表的其他过滤器之前运行。",
        )
        default_budget_tokens: int = Field(
            default=8000,
            description="未在 model_budgets 中列出的任何模型的备用输入 Token 预算。",
        )
        response_headroom_tokens: int = Field(
            default=2000,
            description="为模型回复预留的 Token。在适配前会从预算中扣除。",
        )
        model_budgets_json: str = Field(
            default=(
                '{\n'
                '  "gpt-4o": 120000,\n'
                '  "gpt-4o-mini": 120000,\n'
                '  "claude-3-5-sonnet": 180000,\n'
                '  "gemini-1.5-pro": 900000,\n'
                '  "llama3.1:8b": 6000\n'
                '}'
            ),
            description="模型 ID(或前缀)到输入 Token 预算映射的 JSON 字符串。",
        )

    def __init__(self):
        self.valves = self.Valves()

    # ---- 辅助函数 -----------------------------------------------------------

    @staticmethod
    def _estimate_tokens(content) -> int:
        """~4 个字符代表 1 个 Token 对剪裁预算而言已经足够接近。
        如果需要严格计数,请替换为 tiktoken 或提供商自带的分词器。"""
        if content is None:
            return 0
        if isinstance(content, str):
            return max(1, len(content) // 4)
        # 某些提供商会以 parts 列表的形式传递多模态内容。
        if isinstance(content, list):
            return sum(
                Filter._estimate_tokens(part.get("text", "")) if isinstance(part, dict) else 0
                for part in content
            )
        return 0

    def _message_tokens(self, msg: dict) -> int:
        # 内容 + 用于角色/格式化的微小单条消息开销。
        tokens = self._estimate_tokens(msg.get("content"))
        # 工具调用在 JSON 中携带参数;将它们也计算在内。
        for tc in msg.get("tool_calls") or []:
            args = tc.get("function", {}).get("arguments", "")
            tokens += self._estimate_tokens(args)
        return tokens + 4

    def _budget_for(self, model_id: str) -> int:
        try:
            budgets = json.loads(self.valves.model_budgets_json or "{}")
        except Exception:
            budgets = {}
        if model_id in budgets:
            return int(budgets[model_id])
        # 允许前缀匹配 —— "gpt-4o-2024-11-20" 使用 "gpt-4o" 的预算。
        # 按键长度降序排序,使更具体的前缀优先匹配:
        # "gpt-4o-mini" 必须在 "gpt-4o" 之前匹配。
        for key, value in sorted(budgets.items(), key=lambda kv: -len(kv[0])):
            if model_id.startswith(key):
                return int(value)
        return self.valves.default_budget_tokens

    @staticmethod
    def _repair_tool_calls(other_msgs: list[dict]) -> list[dict]:
        while other_msgs and other_msgs[0].get("role") == "tool":
            other_msgs.pop(0)
        if (
            other_msgs
            and other_msgs[0].get("role") == "assistant"
            and other_msgs[0].get("tool_calls")
        ):
            expected = {tc.get("id") for tc in other_msgs[0]["tool_calls"]}
            seen = {
                m.get("tool_call_id")
                for m in other_msgs[1:]
                if m.get("role") == "tool"
            }
            if not expected.issubset(seen):
                other_msgs.pop(0)
        return other_msgs

    # ---- inlet -------------------------------------------------------------

    async def inlet(self, body: dict) -> dict:
        messages = body.get("messages", [])
        if not messages:
            return body

        model_id = body.get("model", "") or ""
        budget = self._budget_for(model_id) - self.valves.response_headroom_tokens
        if budget <= 0:
            return body  # 配置错误 —— 不要破坏请求,让提供商自行拒绝。

        system_msgs = [m for m in messages if m.get("role") == "system"]
        other_msgs = [m for m in messages if m.get("role") != "system"]

        used = sum(self._message_tokens(m) for m in system_msgs + other_msgs)

        # 每次丢弃一条最旧的非系统消息,直到我们的总量低于预算,
        # 或者已经没有任何可以丢弃的内容。系统消息保留原样;如果仅系统消息
        # 本身就已经超过了预算,提供商同样会拒绝请求,这也是正确的信号
        # (管理员需要精简系统 Prompt)。
        while used > budget and other_msgs:
            dropped = other_msgs.pop(0)
            used -= self._message_tokens(dropped)

        other_msgs = self._repair_tool_calls(other_msgs)

        body["messages"] = system_msgs + other_msgs
        return body

以下是几点值得注意的地方:

  • 一次配置,多处运行。 在 Admin Panel(管理员面板) → Functions(函数)中将此过滤器设置为全局过滤器。model_budgets_json 阀门允许您列举您关心的每个模型;其他任何模型都会退回到使用 default_budget_tokens。管理员可以在运行时微调预算,而无需接触代码。
  • 对模型 ID 进行前缀匹配(最长匹配优先)。 gpt-4o-2024-11-20 会透明地使用 gpt-4o 预算,而 gpt-4o-mini-2024-07-18 则会正确地使用 gpt-4o-mini 预算(更具体的优先胜出)。_budget_for 辅助函数在进行前缀匹配循环前,会先对键按长度进行降序排序——否则字典的插入顺序将决定匹配结果,而 "gpt-4o" 会遮蔽任何将其列在后面的用户的 "gpt-4o-mini"
  • 多模态内容被部分计算。 估算器会遍历包含 parts 列表的内容并累加文本部分。图像 / 音频 / 文件等部分则被计算为零。 对于字符数/4 的启发式估算,这在作为剪裁预算时是完全可以接受的,但如果您在小型提供商处(例如本地的 8k 视觉模型)非常依赖图像输入,请在 _estimate_tokens 内部添加针对每张图像的配额估算(例如,每张图像预留约 255 个 Token 是一个合理的开始)。
  • 相同的工具调用修复。 复用了第一个示例中的机制。这是确保剪裁后请求依然有效的关键代码块。
  • 配置错误时保持正常请求。 如果您因某种原因将预留余量(headroom)设置得比预算还要大,过滤器将会把请求原封不动地传递过去,而不是清空对话。让提供商报错要好于默默丢弃上下文。
检查您的模型 ID

Open WebUI 并不总是会将最原始的提供商 ID 呈现给 body["model"]。如果管理员设置了连接的 prefix_id,每个模型都会被包装为 {prefix}.{raw_id}(例如 openai.gpt-4o)。管道 Function(Pipe-function)的多管道模型会将子模型包装为 {pipe.id}.{sub_id}(例如 anthropic.claude-3-5-sonnet-20241022)。自定义的工作空间模型可以拥有任意 ID,通常是 UUID。

请将模型选择器中显示的精确 ID 复制到 model_budgets_json 中——而不是上游提供商的 ID。如果格式配置错误,请求会默默落入 default_budget_tokens,除非原本能适应真实预算的对话在退回到备用预算时遭遇失败,否则您很难察觉到这一点。

RAG 和原生工具定义是在此过滤器之后添加的

此过滤器运行在 inlet() 阶段,即在 Open WebUI 的 RAG 检索(chat_completion_files_handler)以及原生工具定义被附加到 Payload 之前。这两者都可能会在过滤器完成剪裁之后,为请求添加不容小觑的字节体积。如果您依赖知识库,或者您的模型拥有沉重的内置工具规格(网页搜索 + 记忆 + 代码解释器 + MCP 服务器 + ...),请保留额外的余量,即调高 response_headroom_tokens——它亦可作为防范过滤器后内容增加的通用预算。

如果您需要更高保真度的 Token 计数,请将 _estimate_tokens 替换为 tiktoken.encoding_for_model(model_id).encode(text)(针对 OpenAI 系列)或您提供商自身的分词器。而对于其他所有模型——Anthropic、Gemini、本地模型——字符数/4 的启发式估算已经足够将您安全地控制在限制线以下,只要您为上述的 RAG / 工具添加项留出了足够的余量

您几乎必然需要一个社区过滤器,而不是这一个

本页面上的两个示例均被有意设计得非常精简——它们的存在只是为了展示 inlet() 钩子的形态,并传授关于工具调用修复这一非显而易见的细节。对于实际部署方案,请不要从零开始编写您自己的过滤器,也不要直接原样部署这些示例。请浏览 Open WebUI 社区,选择一个其他人已经历过实战考验的上下文管理过滤器。

生产级的社区过滤器通常会处理上述精简示例所跳过的一系列问题:

  • 针对每个提供商使用真实的分词器 — OpenAI 使用 tiktoken,Claude 使用 Anthropic 的分词器,Google 使用 Gemini 的分词器,本地模型使用 transformers 的分词器。而不是使用字符数/4 的启发式估算。
  • 对图像 / 音频 / 文件等进行正确的 Token 计算 — 为每种内容部分类型匹配特定提供商的配额估算,而非一律记为 "0"。
  • 总结并替换策略 — 当窗口即将溢出时,调用一个低成本模型来总结最旧的一块消息,并用单条摘要消息进行替换,以此保留长期运行的上下文,而不是直接将其遗忘。
  • 按用户 / 按角色定制策略 — 高级用户比较普通用户获得更大的预算;服务账号(Service accounts)获得与人类不同的默认值。
  • 按模型系列定制策略 — 比前缀匹配更加智能化(例如,通过正则或元数据识别出所有的 Claude 3.x Sonnet 变体)。
  • 优先剪裁工具结果或附件 — 在对对话本身动刀前,优先从旧的对话轮次中丢弃巨大的网页抓取内容和 RAG 引用。
  • 带有检查点的滑动窗口总结 — 将运行中的摘要存储在跨轮次的 __metadata__ 中,这样您就不需要在每次请求时都重新进行总结。
  • 硬性消息上限以及面向用户的错误提示 — 通过好用的事件发射器(Event-emitter)向用户发出友好的错误提示:“此对话过长,请开启一个新对话”,而不是静默地丢弃上下文。
  • 可观测性钩子 — 将每一次剪裁决策记录到 Langfuse、OpenLit 或您选择的技术栈中,以便您审计过滤器的实际执行情况。
  • 万物皆可配置阀门 — 管理员可以在运行时微调一切,而无需接触代码。

这一切实现起来并不困难,但如果从上面那些最精简的示例开始开发,将所有这些细节整合在一起需要耗费数周的精力。而在社区网站上,肯定早已有其他人完成了这一步工作。请优先进行搜索。

真的,请优先搜索

当您在寻找上下文管理过滤器时,请留意类似于 context window(上下文窗口)、trim(剪裁)、summarize(总结)、conversation length(对话长度)、token budget(Token 预算)、history limiter(历史限制器)等关键词,以及您所使用模型的提供商名称。按下载热度进行排序——下载量居高不下的过滤器通常早已帮您解决了您尚未遇到过的边缘情况。

用户将会体验到什么

  • 部署了过滤器后,旧的对话轮次会在请求到达模型之前被静默地移除/总结/替换。用户可以像往常一样继续聊天。模型只会根据您的策略“遗忘”较旧的历史。
  • 如果不使用过滤器,超长的对话最终会触及提供商的上下文限制并返回 "prompt is too long" 的错误。用户将必须开启一个新对话。

这两者都是合理的交互设计选择。选择与您的部署方案相契合的那一个即可。

This content is for informational purposes only and does not constitute a warranty, guarantee, or contractual commitment. Open WebUI is provided "as is." See your license for applicable terms.