- 窗口声明:models.json 组级 contextWindow + env LLM_CONTEXT_WINDOW(per-group materialize 播种,0 不写盘、负数报错)。 - 预算三态:CONTEXT_MAX_TOKENS 未设置 ⇒ 按窗口推导;>0 显式覆盖;0|负 关闭压缩(不变)。推导 budget = min(窗口×比例, 窗口−输出保留),比例默认 2/3(CONTEXT_BUDGET_RATIO,1M ⇒ 699050 tokens ≈ 683Ki);输出保留 = max(32768, 该组 maxTokens) 且封顶窗口/2(防配置矛盾时退化成 1 token);窗口未声明 ⇒ 回落 131072 + 恰好一条 WARN。接线在组合根 wire_context.go(唯一决策点)。 - usage/缓存可观测(此前**完全不可观测**):Response 增 CacheHit/CacheMiss/Reasoning tokens + CacheHitRate;解析 OpenAI 兼容(prompt_cache_hit_tokens/prompt_cache_miss_tokens/cached_tokens/reasoning_tokens)与 anthropic(cache_read_input_tokens/cache_creation_input_tokens)两种形状;记录点在适配器 record(换入口不丢成本日志),日志无凭据;单次输入 ≥预算 90% 记 WARN(不阻断)。 - 根治清单「续读」假承诺:抽取期不再按注入预算截断(sidecar 存全文,只受 MaxSidecarBytes=8 MiB 安全上限,ExtractorVersion v1→v2 旧 sidecar 失效重抽);注入预算改在装配期生效 ⇒ ref.Chars/Truncated 是注入侧事实、游标真是继续读的起点;document-read 的 limit 上限改为独立具名常量。 - 顺手修越界:config.go 505 行(超 500)拆出 llm.go。 - 主会话独立验收:隔离 worktree(HEAD + 本切片 39 文件,排除在途 gateway)build/vet 绿、全树 59 包 0 FAIL、linux+darwin 交叉编译绿;变异独立复现(比例改 0.25 ⇒ 推导用例红 262144 vs 699050)。
112 lines
4.7 KiB
Go
112 lines
4.7 KiB
Go
package loop
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||
)
|
||
|
||
// stepOutcome 是循环对单个模型步的内部视图,由
|
||
// agent 的 *agent.StepResult(非流式)或流式路径构建的等价物
|
||
// 归一化而来。
|
||
type stepOutcome struct {
|
||
final bool
|
||
text string
|
||
toolCalls []agent.ToolCall
|
||
stopReason string
|
||
// modelStopReason 是 provider 对该模型调用的原始 stop/finish
|
||
// 原因(Anthropic max_tokens / end_turn / tool_use),记录在 llm_call
|
||
// span 上,使即使该步本身正常结束,截断也可见。
|
||
modelStopReason string
|
||
// model 是服务该调用的路由模型,在流式路径上可知
|
||
//(该路径自行构建请求),在 agent 路径上为空。它
|
||
// 记录在 llm_call span 的日志事件上。
|
||
model string
|
||
// promptTokens 是本次调用的输入侧 token 数(提供商上报时,否则 0)。它只用于
|
||
// 「输入逼近上下文预算」的 WARN(design/04 附录 A.3 第 5 条,不阻断任何流程)。
|
||
promptTokens int
|
||
}
|
||
|
||
// contextPressureRatio 是「单次输入逼近上下文预算」的告警线:达到预算的 90%
|
||
// 就记一条 WARN,**不阻断**(design/04 附录 A.3 第 5 条)。它只是让「快溢出了」
|
||
// 这件事在日志里可见,压缩仍按自己的预算独立判定。
|
||
const contextPressureRatio = 0.9
|
||
|
||
// reportContextPressure 在本次调用的输入 token 达到预算的 contextPressureRatio
|
||
// 时记一条 WARN。预算关闭(<= 0)或提供商没上报用量时不记 —— 没有依据就不吵。
|
||
func (l *Loop) reportContextPressure(promptTokens int) {
|
||
if l.maxContextTokens <= 0 || promptTokens <= 0 {
|
||
return
|
||
}
|
||
if float64(promptTokens) < float64(l.maxContextTokens)*contextPressureRatio {
|
||
return
|
||
}
|
||
l.log.Warn("model input is near the context budget",
|
||
"prompt_tokens", promptTokens, "budget", l.maxContextTokens,
|
||
"ratio", contextPressureRatio, "action", "compaction triggers at the budget")
|
||
}
|
||
|
||
// step 运行单次模型调用。配置了 OnStream 回调时,它
|
||
// 直接驱动适配器的 ChatStream,把增量串给 OnStream 并
|
||
// 应用循环的护栏;否则委托给 agent 的 RunStep,
|
||
// 由后者运行 agent 自身的护栏并记录自己的 CallRecord。整个
|
||
// 调用被包在 llm_call span 中,使 M9 面板能看到每次 LLM 调用。
|
||
func (l *Loop) step(ctx context.Context, sessionID string, messages []model.Message) (*stepOutcome, error) {
|
||
_, llmSpan := l.span(ctx, "llm.call", trace.SpanKindLLM)
|
||
|
||
var (
|
||
out *stepOutcome
|
||
err error
|
||
)
|
||
// 静音(内部自主流程)绕过流式:它既不流向
|
||
// UI,也不付流式路径的额外开销。正常聊天则流式。
|
||
if l.onStream != nil && !isMutedBroadcast(ctx) {
|
||
out, err = l.streamStep(ctx, messages)
|
||
} else {
|
||
out, err = l.agentStep(ctx, sessionID, messages)
|
||
}
|
||
if err != nil {
|
||
l.spanProp(llmSpan, "error", err.Error())
|
||
l.finishSpan(llmSpan, trace.SpanStatusError)
|
||
return nil, fmt.Errorf("loop: model step: %w", err)
|
||
}
|
||
|
||
l.spanProp(llmSpan, "stop_reason", out.stopReason)
|
||
if out.modelStopReason != "" {
|
||
l.spanProp(llmSpan, "model_stop_reason", out.modelStopReason)
|
||
}
|
||
l.spanProp(llmSpan, "tool_calls", len(out.toolCalls))
|
||
// 输入逼近预算的 WARN(不阻断):覆盖流式路径(agent 路径不经本函数的上报,
|
||
// promptTokens 为 0 时静默跳过)。
|
||
l.reportContextPressure(out.promptTokens)
|
||
l.recordLLMDone(llmSpan, out, l.agentFor(ctx))
|
||
l.finishSpan(llmSpan, trace.SpanStatusOK)
|
||
return out, nil
|
||
}
|
||
|
||
// agentStep 把非流式步委托给该 turn 的 agent,由后者运行输入
|
||
// 与输出护栏、调用模型并解码所有工具调用。
|
||
//
|
||
// 工具面与流式路径**同源**(audit P0-12):两条路径都取这一处过滤结果
|
||
// (capacity 允许集 + design/11 §3 会话环境安全门),区别只在于流式路径在
|
||
// 循环里组装请求时直接把它写进 model.Request,而本路径经 ctx 下传给 agent
|
||
// 广告。agent 侧因此不需要、也不允许自行过滤,两份实现无从漂移;静音内部
|
||
// 流程(backthink、子代理)与前端聊天看到的是同一份工具面。
|
||
func (l *Loop) agentStep(ctx context.Context, sessionID string, messages []model.Message) (*stepOutcome, error) {
|
||
ctx = agent.WithToolSurface(ctx, l.toolSchemas(ctx, messages))
|
||
sr, err := l.agentFor(ctx).RunStep(ctx, sessionID, messages)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("loop: agent step: %w", err)
|
||
}
|
||
return &stepOutcome{
|
||
final: sr.Final,
|
||
text: sr.Text,
|
||
toolCalls: sr.ToolCalls,
|
||
stopReason: sr.StopReason,
|
||
modelStopReason: sr.ModelStopReason,
|
||
}, nil
|
||
}
|