Skip to content

Context budget: stop estimating fit; the provider decides, the Maka window is a user target #4559

Description

@Joob1n

Status. The design is settled and split into a series; this body is the design. It replaces the original proposal, which stays available in this issue's edit history — what changed and why is tabulated at the end. #4574 was the first implementation attempt and is closed in favour of the series below, with its review findings mapped in its closing comment.

Follow-up to #4458 and #4486. #4486 removed the fabricated capacity and the two terminal gates. This issue takes the remaining step: the runtime stops estimating whether context fits. Every "does it fit" question is answered by a provider; every trigger is a real number.

The design: three modules, recursion bounded at one

One module judges a provider response; the other two use it. The send module may call the compaction module. The compaction module never calls the send module and never calls itself. That, plus one compaction per send, is why nothing here can loop.

Judgement module

Input: one provider response or error. Output: one of five. Shared, so "was this cut" and "was this a size rejection" have exactly one implementation.

├ completed normally      no error, an ordinary finish reason
├ cut off                 finishReason = length: the model stopped for lack of room
├ rejected, size          the error classifies as a context-length rejection
├ rejected, unrecognised  any other error; nothing is inferred from it
└ usage suspicious        completed, but usage is missing, did not grow, or exceeds
                          the window the model itself reports

Send module

Input: the baseline plus the new message. baseline is the last accepted request's real input + output — an upper bound on what carries forward, never an underestimate, since a wire that does not resend reasoning sends less next time. The new message is deliberately not measured; the provider judges it.

① Threshold
     no declared window, or no baseline   -> skip this step entirely
     baseline + reply reserve >= window   -> call the compaction module
                                             (this spends the send's only call)

② Send

③ Judge
     completed          -> record the new baseline, persist the anchor
     cut off            -> no action. The reply may have been cut because the
                           provider ran out of window room, or because the
                           provider's own output cap is lower than the one Maka
                           sends. Those are indistinguishable from outside, and
                           an indistinguishable signal must not drive an action.
     rejected, size     -> compaction not yet called this send: call it, resend
                           already called: report that this message is too large
                           on its own
     rejected, unknown  -> report it as it came
     usage suspicious   -> record the baseline and write the matching note

Reply reserve is min(2 × last reply, 8000): measured from the reply the model actually wrote, not the largest it could write.

Compaction module

Input: the event ledger, the boundary the last accepted input covered, the last reply's token count. Output: a smaller baseline. Entered at most once per send.

① Choose the fold range
     default: everything except the live head
     live head = the user message this turn is answering, plus any in-flight
                 tool call and its result. Neither may become a summary.

② Send it to the summarizer (same model, same connection, no threshold step)

③ Judge
     completed  -> is the summary usable? required sections present, no
                   unterminated fence. Malformed gets one stricter repair.
     cut off    -> one retry with a shorter prompt, then fail open
     rejected, size
                -> retreat to the span the LAST ACCEPTED INPUT covered. That
                   span was accepted by this model on this connection, so it is
                   provably within capacity, where halving the range is a guess.
                   The last reply's own span folds separately above 24,000
                   tokens. One retry, then fail open.
     rejected, unknown -> fail open

Failing open always means: send the request unfolded and let the provider decide. Compaction never terminates a turn.

Invariants

  • The local side never decides that a request does not fit. Only the provider terminates.
  • Everything Maka does on its own is reversible: one fold, and notes.
  • Inputs are provider-reported numbers and the user's own declaration, nothing else. A signal that cannot be told apart from a benign cause drives nothing.
  • The declared window is a target, not a limit. Exceeding it is reported, not prevented.

The series

PR Module What it carries Depends on
#4653 Send Threshold and reply reserve, persisted anchor, one compaction per send, "this message is too large", stream-usage collection with the strict-relay retreat, the summarizer request shape, and all five system notes. One epoch move.
PR 2 Compaction Retreat to the last accepted input boundary instead of halving; the last reply's span folded separately above 24,000 tokens. #4653
#4575 UI Settings: the model's reported window shown beside the declaration, with one-click apply. #4653
#4576 UI Composer: read-only context-usage indicator fed by the persisted anchor. #4653
PR 5 Send "Compact and retry" for a rejection the classifier does not recognise. Tracked on #4623. #4653
optional Extract the judgement module so both callers share one implementation. Pure refactor. PR 2

What discussion changed, against the original proposal

Original Final Why
Metadata window seeds a default declaration Declaration only; metadata is a hint with one-click apply Maka decides for nobody, including the user
Reserve is the model's declared output limit min(2 × last reply, 8000) On k3-256k the limit is half the window, so reserving it folds at 50% utilisation
Proactive and reactive folds are independent One compaction per send The fold already covers everything but the live head; a second one costs the most recent context and buys a small tail
finishReason: length folds once Drives nothing Window exhaustion and the provider's own lower output cap are indistinguishable
A fold rejected as too large halves its range Retreats to the last accepted input boundary That boundary is provably within capacity; halving is a guess
Two notes Five The silent cases each need their own sentence

The original proposal left four points open; all are resolved: reasoning tokens are counted whole in the baseline (high and safe); with no declaration the runtime is purely reactive and says so through the notes; manual /compact uses the same module and clears the baseline; and the signed delta is dropped, which @Astro-Han accepted on #4486.

Refs #4458, #4486, #4283, #4623, #4634.

简体中文

状态:设计已定稿并拆成系列,本正文即设计本身。它取代了原提案,原文仍可在本 issue 的编辑历史中查看,改动对照见文末表格。#4574 是第一次实现尝试,已关闭,改为下方系列,其评审发现的去向写在那条关闭评论里。

#4458#4486 的后续。#4486 删掉了编造的容量和两道终止闸门;本 issue 走完剩下的一步:runtime 不再估算上下文放不放得下。所有「放不放得下」由供应商回答,所有触发由真实数字驱动。

设计:三个模块,递归深度为 1

一个模块负责判定一次供应商响应,另外两个都用它。发送模块可以调压缩模块;压缩模块不调发送模块,也不调自己。加上「每次发送只压一次」,整条路径不可能成环。

判定模块

输入:一次供应商响应或错误。输出五选一。两个模块共用,所以「有没有被截断」「是不是尺寸问题」只有一处实现。

├ 正常完成        没有错误,正常的结束原因
├ 被截断          finishReason = length,模型是没地方写了才停的
├ 拒绝,尺寸      错误能被分类为上下文超长
├ 拒绝,认不出    其他错误,不做任何推测
└ usage 可疑      完成了,但用量缺失、没增长,或超过模型自己上报的窗口

发送模块

输入:baseline 加新消息。baseline 是上次被接受请求的真实 input + output,它是「带进下一次请求的量」的上界,不会低估——不回传推理内容的线路上,下一次 input 更小。新消息故意不量,由供应商裁决。

① 阈值
     没声明窗口,或没有 baseline      → 整步跳过
     baseline + 回复预留 ≥ 窗口       → 调压缩模块(用掉本次发送唯一的一次机会)

② 发送

③ 判定
     正常完成      → 记新 baseline,落盘锚点
     被截断        → 不做动作。回复被截可能是供应商窗口没空间了,也可能是它
                     自己的输出上限比 Maka 发过去的更低。这两者从外部无法区分,
                     无法区分的信号不该驱动动作。
     拒绝,尺寸    → 本次发送还没调过压缩 → 调一次,重发
                     已经调过 → 报「这条消息本身太大」
     拒绝,认不出  → 原样报错
     usage 可疑    → 记 baseline,并写对应提示

回复预留是 min(上次回复 × 2, 8000):量的是模型实际写出的回复,不是它可能写出的最大值。

压缩模块

输入:事件账本、上次被接受的 input 所覆盖的边界、上次回复的 token 数。输出:更小的 baseline。每次发送最多进来一次。

① 定折叠范围
     默认:除活动头部之外的全部
     活动头部 = 当前轮正在回答的用户消息,以及正在飞的工具调用与其结果。
                这两样不能变成摘要,否则请求本身非法。

② 发给摘要模型(同模型同连接,没有阈值步骤)

③ 判定
     正常完成   → 摘要能用吗?必需小节齐全、没有未闭合的代码围栏。
                  格式错给一次更严格的修复。
     被截断     → 换更短的提示词重试一次,再失败放行
     拒绝,尺寸 → 退到「上次被接受的 input 所覆盖的那一段」。这一段被这个模型、
                  这条连接处理过,是可证明装得下的边界,而砍半是猜。
                  上次回复那一段超过 24,000 token 时单独折叠。重试一次后放行。
     拒绝,认不出 → 放行

放行永远是同一个含义:不折叠,原样发出,让供应商裁决。压缩从不终止一轮。

不变量

  • 本地永远不判定「装不下」,只有供应商能终止。
  • Maka 主动做的一切都是可逆的:一次折叠,以及写提示。
  • 输入只有供应商给的数字和用户自己的声明。与良性原因无法区分的信号,什么都不驱动。
  • 用户声明的窗口是目标不是限制,超了要报告,不要阻止。

系列拆分

PR 模块 内容 依赖
#4653 发送 阈值与回复预留、锚点落盘、每次发送只压一次、「这条消息本身太大」、用量采集与严格中转回退、摘要请求形状、五种提示。一次 epoch 变更
PR 2 压缩 折叠被拒时退到上次被接受的 input 边界(取代砍半);上次回复那段超过 24,000 token 时单独折叠 #4653
#4575 界面 设置页显示模型上报窗口,一键填入 #4653
#4576 界面 输入框只读的上下文使用率指示器 #4653
PR 5 发送 分类不出的拒绝加「压缩后重试」,记在 #4623 #4653
可选 抽出判定模块,两个调用方共用一份实现。纯重构 PR 2

讨论改变了什么(相对原提案)

原提案 定稿 原因
元数据窗口作为默认声明 只认用户声明,元数据仅作提示加一键填入 Maka 不替任何人做决定,包括用户
预留取模型声明的输出上限 min(上次回复 × 2, 8000) k3-256k 的上限是窗口的一半,照它预留会在 50% 利用率就折叠
主动与被动折叠各自独立 每次发送只压一次 折叠已覆盖除活动头部外的全部,第二次代价是最近的原文,收益只有一小段尾巴
finishReason: length 折一次 什么都不驱动 窗口不够与供应商自身更低的输出上限无法区分
折叠被拒就把范围砍半 退到上次被接受的 input 边界 那个边界可证明装得下,砍半是猜
两种提示 五种 每一种静默情况都需要自己的一句话

原提案的四个待定问题均已解决:推理 token 在 baseline 里整体计入(偏高、安全);未声明窗口时 runtime 纯被动,并通过提示告知;手动 /compact 走同一个模块并清空 baseline;带符号的 delta 去掉,@Astro-Han 已在 #4486 接受。

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions