Files
Cyrene_v2/internal/runtime/loop/step.go
T
AskaEth 72b95df22b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(config,runtime): 上下文预算按模型窗口推导 + usage/缓存可观测 + sidecar 存全文(T1)
- 窗口声明: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)。
2026-10-04 17:57:08 +08:00

112 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}