- 窗口声明: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)。
178 lines
7.7 KiB
Go
178 lines
7.7 KiB
Go
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),
|
||
})
|
||
}
|