Files
Cyrene_v2/internal/runtime/loop/stream.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

178 lines
7.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"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"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"
)
// guardrailCheckTimeout 限制一次后台输出侧护栏检查。它只管辖脱离
// 出来的检查 context,因此一个挂起的检查不会让 goroutine 泄露到进程
// 生命周期结束,也不会拖住任何回合。
const guardrailCheckTimeout = 30 * time.Second
// auditGuardrailOutputBlock 是输出侧护栏在后台判定拦截时发出的审计
// "what"。输出侧检查已移出关键路径(裁决 13.4.1),拦截因此必须闭环:
// 一条审计事件 + guardrail 节点上的 verdict,「异步」不等于「可绕过」。
const auditGuardrailOutputBlock = "guardrail.output.block"
// streamStep 驱动一次流式模型调用。它是
// agent.RunStep 的流式对应物:它从 agent 的公开表面构建请求、
// 运行循环的输入护栏、把增量流经 OnStream、运行输出
// 护栏并解码所有工具调用。由于 RunStep 不暴露流式
// 路径,该方法归循环所有;它镜像 agent 的语义,使两者
// 除增量回调外行为一致。
//
// 两侧护栏的分工(裁决 13.4.1,design/04 §5):**输入侧是模型调用前的同步
// 前置门**(恶意输入不得先消耗一次模型调用),**输出侧是异步并行检查**
// (模型产出不等待检查即可继续流转,结果异步落定)。
func (l *Loop) streamStep(ctx context.Context, messages []model.Message) (*stepOutcome, error) {
ag := l.agentFor(ctx)
adapter := ag.Adapter()
if adapter == nil {
return nil, fmt.Errorf("loop: stream step: %w", ErrNoAdapter)
}
// 每个 step 解析一次该角色的 agent 与推理旋钮:同一对取值
// 既用于请求,也用于逐增量的广播门,因此角色切换
// 绝不会在流中途用旧的广播开关套用新的提示词。
knobs := l.knobsFor(ctx)
req := l.buildRequest(ctx, messages, ag, knobs)
if l.guards != nil {
// 输入侧同步前置门:检查包在自己的 guardrail span 里
// (裁决 11.4.2),因此护栏也进了全链路流程图。
gctx, gspan := l.span(ctx, spanNameGuardrailInput, trace.SpanKindGuardrail)
verdict, err := l.guards.CheckInput(gctx, &req)
l.finishGuardrailSpan(gspan, spanNameGuardrailInput, verdict, err)
if err != nil {
return nil, fmt.Errorf("loop: check input: %w", err)
}
if verdict != nil && !verdict.Allow {
return &stepOutcome{final: true, text: verdict.Reason, stopReason: agent.StopReasonGuardrailInput}, nil
}
}
// 一次模型流(一次 ChatStream 调用)取一个 streamId:该流的
// 每一帧共享它,而多步工具轮的后续模型调用取新的 id
// (design/04 §1.1)。它与 TurnID 分工:turnId 标记整个回合,
// streamId 标记回合内的一条流式消息。
streamID := newStreamID()
resp, err := adapter.ChatStream(ctx, req, func(ev model.StreamEvent) {
// 在循环边界打上 session/turn 身份:适配器运行在
// 循环之下,无法知道该流属于哪个会话,
// 因此循环用它被驱动的 context 填充(design/16 §4.2),
// 前端便能把增量路由到活跃会话。
ev.SessionID = loopSession(ctx)
ev.TurnID = loopTurn(ctx)
ev.StreamID = streamID
// 为内部自主流程(backthink)抑制 UI 广播,
// 其中间推理不得泄露进聊天流;当该 turn 的角色
// 关闭思考帧时(showToUser: false)也抑制。两处门
// 都只影响广播:模型产出的响应
// 不变,因此展示与持久化保持解耦。
if l.onStream != nil && !isMutedBroadcast(ctx) && broadcasts(ev, knobs.ThinkingShowToUser) {
l.onStream(ev)
}
})
if err != nil {
return nil, fmt.Errorf("loop: chat stream: %w", err)
}
// 输出侧不阻塞回合:检查在后台跑,本回合带着模型产出继续。
l.checkOutputAsync(ctx, &resp)
if calls, ok := decodeToolCalls(resp); ok {
return &stepOutcome{final: false, toolCalls: calls, stopReason: agent.StopReasonToolCalls,
modelStopReason: resp.StopReason, model: resp.Model, promptTokens: resp.PromptTokens}, nil
}
return &stepOutcome{final: true, text: resp.Content, stopReason: agent.StopReasonStop,
modelStopReason: resp.StopReason, model: resp.Model, promptTokens: resp.PromptTokens}, nil
}
// checkOutputAsync 在后台运行输出侧护栏检查(裁决 13.4.1:真并发、不阻塞
// 主会话)。启动 span 与「运行中」占位节点留在同步路径上,节点的出现顺序
// 才与真实发生顺序一致;检查本身脱离本步的 context 跑(保留会话/actor 值,
// 另加超时兜底),因此慢检查绝不拖住回合。
//
// 结果闭环在 guardrail 节点上(allow / 耗时 / 脱敏后的 Reason,裁决 11.4.2),
// 判定拦截时再发一条审计事件 —— 输出侧的拦截始终留痕,只是不再挡住已产出的
// 回复(这正是「真并发」与「可绕过」的区别:输入侧的前置门依旧同步拦下)。
func (l *Loop) checkOutputAsync(ctx context.Context, resp *model.Response) {
if l.guards == nil || resp == nil {
return
}
gctx, span := l.span(ctx, spanNameGuardrailOutput, trace.SpanKindGuardrail)
bg, cancel := context.WithTimeout(context.WithoutCancel(gctx), guardrailCheckTimeout)
go func() {
defer cancel()
verdict, err := l.guards.CheckOutput(bg, resp)
l.finishGuardrailSpan(span, spanNameGuardrailOutput, verdict, err)
if verdict != nil && !verdict.Allow {
// Reason 由 guardrail 包保证不含被匹配的密钥(其契约),
// 这里再经 RedactText 走一遍凭据形状清洗 + 截断,使审计面
// 与出图面用同一判据。
l.emitAudit(AuditEvent{
Who: string(sessionEnvFrom(bg).ActorEnv),
What: auditGuardrailOutputBlock,
Result: trace.RedactText(verdict.Reason),
})
}
}()
}
// broadcasts 报告某流式事件是否可推给 UI。只有
// 思考帧会被扣下,且仅当该 turn 的角色把
// showToUser 解析为 false 时;文本、工具调用与工具进度帧
// 从不受影响,因此关闭思考不会改变客户端所见
// 的答案本身。
func broadcasts(ev model.StreamEvent, showToUser *bool) bool {
if !isThinkingKind(ev.Kind) {
return true
}
return showToUser == nil || *showToUser
}
// isThinkingKind 报告 kind 是否为推理分段事件之一。
func isThinkingKind(kind model.StreamKind) bool {
switch kind {
case model.StreamKindThinkingStart, model.StreamKindThinkingDelta, model.StreamKindThinkingEnd:
return true
default:
return false
}
}
// newStreamID 返回新的随机十六进制标识,用于把一串流帧关联到
// 同一次模型流(design/04 §1.1 streamId)。它消耗 crypto/rand 源,
// 源不可用时回退到纳秒时间戳,因此总能产出 id(空的 streamId 会
// 静默破坏客户端关联)。
func newStreamID() string {
var b [8]byte
if _, err := rand.Read(b[:]); err == nil {
return "stream-" + hex.EncodeToString(b[:])
}
return "stream-" + time.Now().UTC().Format("20060102150405.000000000")
}
// emitTurnBoundary 在循环的回合边界发出 turn.start / turn.end 帧
// (design/04 §1.1)。它复用流式路径的广播门:未配置 OnStream 或
// 被静音的内部流程(backthink / 子代理)不发出。两帧不带载荷 ——
// 帧壳上的 session/turn 身份就是边界信号,回合的终态信息仍由
// hub 在 Run 返回后发出的 stream_end 携带。
func (l *Loop) emitTurnBoundary(ctx context.Context, kind model.StreamKind) {
if l.onStream == nil || isMutedBroadcast(ctx) {
return
}
l.onStream(model.StreamEvent{
Kind: kind,
SessionID: loopSession(ctx),
TurnID: loopTurn(ctx),
})
}