mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
fix(agent): 空转回合注入续跑指令,复用空响应重试次数
模型有时一整轮只输出思考、既不给正文也不调工具,harness 视为自然结束 (end_turn 且无 tool_use),直接以 completed + 空总结收场,一条未完成的意图 断在半路,表现为「任务正常结束但没有文字总结」。五层 LLM 重试均够不着它 (非错误路径),SDK 空响应重试也够不着(以是否 yield 过事件判空,而思考增量 本身就是事件)。 work 的 Stop 钩子现识别这种回合并注入一条续跑指令,让模型带着已有思考继续 执行下一步——不选原样重发,因这类空转由提示词与上下文形状决定,是稳定行为 而非随机抽风。次数复用 LLM 页「空响应重试次数」(默认 2、-1 关闭),是一条 意图的总量闸而非连续次数(harness 已限死连续空转只推一次)。
This commit is contained in:
1 parent
eb5290039a
commit
3c2a60fba7
4 files changed
+255
-4
No files matched your search
@@ -4,6 +4,13 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Agent
|
||||
|
||||
#### 修复的问题
|
||||
|
||||
- 修复 work 因**空转回合**提前中断的问题:模型有时会用一整轮只输出思考,既不给正文也不调工具,此时 harness 看到的是一次自然结束(`end_turn` 且无 `tool_use`),直接以 `completed` + 空总结收场——一条还没做完的意图就断在半路,表现为「任务正常结束但没有任何文字总结」。五层 LLM 重试一层都够不着它:它不是错误,同 provider 安全窗口重试的入口是"流失败",熔断在 `err == nil` 时直接判成功,意图重跑只认 `model_error`;SDK 的空响应重试也够不着,因为那层以"有没有 yield 过事件"判空,而思考增量本身就是事件。现在 work 的 Stop 钩子会识别这种回合并注入一条续跑指令,让模型带着已经产出的思考继续执行下一步——刻意不选"原样重发",因为这类空转通常由提示词与上下文形状决定,是稳定行为而非随机抽风,重发只会让模型再想一遍。
|
||||
- 次数复用 LLM 页「重试与退避」里的**空响应重试次数**(两者都在回答"模型完成了却没产出实质内容",只是判据与手段不同),默认 2 次,填 `-1` 即关掉、退回原先"空转即收场"的行为。它是一条意图的**总量**上限而非连续次数:harness 自身已限死连续空转只推一次,只有真正发生过工具回合配额才刷新,所以这个数挡的是「工具 → 空转 → 推 → 工具 → 空转」这类循环把意图预算耗光。触发时日志记 `[work <worker> · #<意图>] 空转回合…注入续跑指令 (n/N)`。设计见 `docs/LLM重试设计.md` §1.1。
|
||||
|
||||
## [0.3.8] - 2026-09-09
|
||||
|
||||
### LLM
|
||||
|
||||
+76
-4
@@ -527,9 +527,52 @@ func (e *Engine) drainSteer(intentID int64) (string, bool) {
|
||||
// before each tool call it drains a queued course-correction (if any) and blocks the
|
||||
// call, handing the message back to the model — which re-plans its next step instead
|
||||
// of running the tool. No queued message → the guard behaves exactly as before.
|
||||
// 它同时负责「空转回合」的续跑,见 Stop。
|
||||
type steerHooks struct {
|
||||
inner harness.HookRunner
|
||||
drain func() (string, bool)
|
||||
// nudges 是本条意图已注入的空转续跑次数,上限 limit。指针:harness 持有的是
|
||||
// steerHooks 的值拷贝,计数必须共享同一份。
|
||||
nudges *atomic.Int64
|
||||
// limit 是空转续跑的次数上限,由 Engine.emptyTurnNudgeLimit() 从「空响应重试
|
||||
// 次数」解析而来。<=0 = 不介入(用户显式关掉了这层)。
|
||||
limit int
|
||||
// label 形如 "worker-1 · #42",只用于日志。
|
||||
label string
|
||||
}
|
||||
|
||||
// 空转回合(只有思考、既无正文也无工具调用)续跑次数的默认值,与 SDK 空响应重试的
|
||||
// 内置默认(norma/llm/openai.go 的 emptyResponseRetries)保持一致——两层共用同一个
|
||||
// 旋钮,不配置时的行为也该对齐。解析见 Engine.emptyTurnNudgeLimit。
|
||||
//
|
||||
// 注意这个数是「一条意图的总量」,不是「连续几次」:harness 自身的 stopHookActive
|
||||
// 已经限死了连续空转只推一次——推完那一轮若还是空转,Stop 钩子不会再被调到,run 直接
|
||||
// 收场;只有真正发生过一次工具回合,配额才刷新(norma/harness/query.go:534)。所以这个
|
||||
// 闸挡的是「工具 → 空转 → 推 → 工具 → 空转」这种病态循环,别让它把意图预算耗光。
|
||||
const defaultEmptyTurnNudges = 2
|
||||
|
||||
// emptyTurnNudge 是空转回合注入的续跑指令。
|
||||
//
|
||||
// 这种回合在 harness 眼里是一次自然结束(stop_reason=end_turn 且无 tool_use),五层
|
||||
// LLM 重试一层都不适用——它不是错误,是模型「想完了但没动手」。SDK 的空响应重试也
|
||||
// 够不着:它以「有没有 yield 过事件」判空,而思考增量本身就是事件(norma/llm/openai.go
|
||||
// 的 SEThinkingDelta),所以 thinking-only 不算空。何况那层是原样重发整个 prompt,
|
||||
// 对这种由上下文形状决定的空转,重发只会让模型再想一遍。这里换成追加一条指令,让它
|
||||
// 带着已经产出的思考继续,输入变了才有理由给出不同的行为。
|
||||
const emptyTurnNudge = "【空转提醒】你上一轮只输出了思考过程,既没有给出正文回复,也没有调用任何工具," +
|
||||
"这一轮等于没有产出。请直接执行你刚才想好的下一步:要么调用工具,要么给出结论文字。不要重复思考。"
|
||||
|
||||
// isThinkingOnlyTurn reports whether the latest assistant turn produced neither
|
||||
// text nor a tool call — i.e. the model spent the whole round thinking.
|
||||
func isThinkingOnlyTurn(messages []llm.Message) bool {
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
m := messages[i]
|
||||
if m.Role != llm.RoleAssistant {
|
||||
continue
|
||||
}
|
||||
return strings.TrimSpace(m.Text()) == "" && len(m.ToolUses()) == 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h steerHooks) PreToolUse(ctx context.Context, name string, input []byte) (bool, string, []byte) {
|
||||
@@ -549,11 +592,31 @@ func (h steerHooks) PostToolUse(ctx context.Context, name string, input, result
|
||||
}
|
||||
}
|
||||
|
||||
// Stop 在 guard 原有语义之上补一层「空转回合」续跑:模型只输出了思考、既没给正文
|
||||
// 也没调工具时,harness 会把它当成自然结束并以空 summary 收场(query.go 的
|
||||
// ReasonCompleted + asst.Text()),一条本来还没做完的意图就这样断在半路。此时注入
|
||||
// 一条续跑指令,让模型带着已有思考接着走。
|
||||
func (h steerHooks) Stop(ctx context.Context, messages []llm.Message) (bool, []string, string) {
|
||||
var (
|
||||
prevent bool
|
||||
blocking []string
|
||||
msg string
|
||||
)
|
||||
if h.inner != nil {
|
||||
return h.inner.Stop(ctx, messages)
|
||||
prevent, blocking, msg = h.inner.Stop(ctx, messages)
|
||||
}
|
||||
return false, nil, ""
|
||||
// inner 已经决定硬停、或已经要注入自己的续跑消息 → 尊重它,不再叠加。
|
||||
// limit<=0 = 用户把「空响应重试次数」配成了 -1,即显式关掉这层。
|
||||
if prevent || len(blocking) > 0 || h.nudges == nil || h.limit <= 0 || !isThinkingOnlyTurn(messages) {
|
||||
return prevent, blocking, msg
|
||||
}
|
||||
n := h.nudges.Add(1)
|
||||
if n > int64(h.limit) {
|
||||
log.Printf("[work %s] 空转回合(仅思考、无正文无工具)已达续跑上限 %d,放行收场", h.label, h.limit)
|
||||
return prevent, blocking, msg
|
||||
}
|
||||
log.Printf("[work %s] 空转回合(仅思考、无正文无工具),注入续跑指令 (%d/%d)", h.label, n, h.limit)
|
||||
return false, []string{emptyTurnNudge}, ""
|
||||
}
|
||||
|
||||
// KillWork cancels the in-flight work running intentID (planner's kill_work tool).
|
||||
@@ -956,8 +1019,17 @@ func (e *Engine) runIntent(ctx context.Context, t *Task, name string, worker *ag
|
||||
a.NodeID, a.Worker = &nid, name
|
||||
emit(a)
|
||||
}
|
||||
workCtx = intercept.WithTaskContext(workCtx, t.ID, fmt.Sprintf("%s · #%d", name, iid), taskEmit)
|
||||
hooks := steerHooks{inner: t.Guard.Hooks(), drain: func() (string, bool) { return e.drainSteer(iid) }}
|
||||
label := fmt.Sprintf("%s · #%d", name, iid)
|
||||
workCtx = intercept.WithTaskContext(workCtx, t.ID, label, taskEmit)
|
||||
// nudges 有意建在 model_error 重跑循环之外:空转续跑的上限是「这条意图」的总量,
|
||||
// 重跑一轮不该把额度清零重来。
|
||||
hooks := steerHooks{
|
||||
inner: t.Guard.Hooks(),
|
||||
drain: func() (string, bool) { return e.drainSteer(iid) },
|
||||
nudges: &atomic.Int64{},
|
||||
limit: e.emptyTurnNudgeLimit(),
|
||||
label: label,
|
||||
}
|
||||
wTaskID, _ := strconv.ParseInt(t.ID, 10, 64)
|
||||
e.BeginLLMCall(t.ID)
|
||||
var reason harness.TerminalReason
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
)
|
||||
|
||||
// 空转回合(仅思考、无正文无工具)的识别与续跑,见 steerHooks.Stop。
|
||||
|
||||
func assistantThinking(text string) llm.Message {
|
||||
return llm.Message{Role: llm.RoleAssistant, Content: []llm.ContentBlock{
|
||||
{Type: llm.BlockThinking, Thinking: text, Signature: "sig"},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestIsThinkingOnlyTurn(t *testing.T) {
|
||||
toolUse := llm.Message{Role: llm.RoleAssistant, Content: []llm.ContentBlock{
|
||||
{Type: llm.BlockThinking, Thinking: "先扫端口"},
|
||||
{Type: llm.BlockToolUse, ID: "t1", Name: "run_nuclei"},
|
||||
}}
|
||||
cases := []struct {
|
||||
name string
|
||||
msgs []llm.Message
|
||||
want bool
|
||||
}{
|
||||
{"仅思考", []llm.Message{llm.UserText("开始"), assistantThinking("想想")}, true},
|
||||
{"思考+工具", []llm.Message{llm.UserText("开始"), toolUse}, false},
|
||||
{"思考+正文", []llm.Message{assistantThinking("想想"), {
|
||||
Role: llm.RoleAssistant,
|
||||
Content: []llm.ContentBlock{{Type: llm.BlockThinking, Thinking: "x"}, llm.TextBlock("结论")},
|
||||
}}, false},
|
||||
{"正文只有空白字符", []llm.Message{{
|
||||
Role: llm.RoleAssistant,
|
||||
Content: []llm.ContentBlock{{Type: llm.BlockThinking, Thinking: "x"}, llm.TextBlock(" \n ")},
|
||||
}}, true},
|
||||
{"完全空的 assistant 回合", []llm.Message{{Role: llm.RoleAssistant}}, true},
|
||||
// 工具结果是 user 角色,判定必须回溯到它前面那条 assistant,而不是就近误判。
|
||||
{"最后一条是工具结果", []llm.Message{toolUse, {
|
||||
Role: llm.RoleUser,
|
||||
Content: []llm.ContentBlock{{Type: llm.BlockToolResult, ToolUseID: "t1"}},
|
||||
}}, false},
|
||||
{"没有 assistant 消息", []llm.Message{llm.UserText("开始")}, false},
|
||||
{"空历史", nil, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := isThinkingOnlyTurn(c.msgs); got != c.want {
|
||||
t.Fatalf("isThinkingOnlyTurn = %v, want %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fakeHooks 是一个可编程的 inner HookRunner,用来验证 steerHooks 对 inner 决定的尊重。
|
||||
type fakeHooks struct {
|
||||
prevent bool
|
||||
blocking []string
|
||||
msg string
|
||||
}
|
||||
|
||||
func (f fakeHooks) PreToolUse(context.Context, string, []byte) (bool, string, []byte) {
|
||||
return false, "", nil
|
||||
}
|
||||
func (f fakeHooks) PostToolUse(context.Context, string, []byte, []byte, bool) {}
|
||||
func (f fakeHooks) Stop(context.Context, []llm.Message) (bool, []string, string) {
|
||||
return f.prevent, f.blocking, f.msg
|
||||
}
|
||||
|
||||
func TestSteerHooksStopNudgesEmptyTurn(t *testing.T) {
|
||||
empty := []llm.Message{assistantThinking("我应该先枚举子域名")}
|
||||
|
||||
t.Run("空转回合注入续跑指令", func(t *testing.T) {
|
||||
h := steerHooks{nudges: &atomic.Int64{}, limit: defaultEmptyTurnNudges, label: "worker-1 · #1"}
|
||||
prevent, blocking, _ := h.Stop(context.Background(), empty)
|
||||
if prevent {
|
||||
t.Fatal("空转回合不应硬停")
|
||||
}
|
||||
if len(blocking) != 1 || blocking[0] != emptyTurnNudge {
|
||||
t.Fatalf("blocking = %v, want [emptyTurnNudge]", blocking)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("有正文或工具时不介入", func(t *testing.T) {
|
||||
h := steerHooks{nudges: &atomic.Int64{}, limit: defaultEmptyTurnNudges}
|
||||
normal := []llm.Message{{
|
||||
Role: llm.RoleAssistant,
|
||||
Content: []llm.ContentBlock{llm.TextBlock("已完成扫描,未发现开放端口")},
|
||||
}}
|
||||
if _, blocking, _ := h.Stop(context.Background(), normal); blocking != nil {
|
||||
t.Fatalf("正常收场被误判为空转: %v", blocking)
|
||||
}
|
||||
if n := h.nudges.Load(); n != 0 {
|
||||
t.Fatalf("未介入时不应计数, got %d", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("达到上限后放行收场", func(t *testing.T) {
|
||||
const limit = 5 // 用户把「空响应重试次数」配成 5
|
||||
h := steerHooks{nudges: &atomic.Int64{}, limit: limit}
|
||||
for i := 1; i <= limit; i++ {
|
||||
if _, blocking, _ := h.Stop(context.Background(), empty); len(blocking) != 1 {
|
||||
t.Fatalf("第 %d 次应仍在配额内, blocking = %v", i, blocking)
|
||||
}
|
||||
}
|
||||
if _, blocking, _ := h.Stop(context.Background(), empty); blocking != nil {
|
||||
t.Fatalf("超出上限仍在注入: %v", blocking)
|
||||
}
|
||||
})
|
||||
|
||||
// 「空响应重试次数」配 -1 = 关掉这层,emptyTurnNudgeLimit 解析成 0。
|
||||
t.Run("配置关闭时不介入", func(t *testing.T) {
|
||||
h := steerHooks{nudges: &atomic.Int64{}, limit: 0}
|
||||
if _, blocking, _ := h.Stop(context.Background(), empty); blocking != nil {
|
||||
t.Fatalf("已关闭仍在注入: %v", blocking)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("inner 决定硬停时不叠加", func(t *testing.T) {
|
||||
h := steerHooks{inner: fakeHooks{prevent: true, msg: "guard 拒绝收场"}, nudges: &atomic.Int64{}, limit: defaultEmptyTurnNudges}
|
||||
prevent, blocking, msg := h.Stop(context.Background(), empty)
|
||||
if !prevent || msg != "guard 拒绝收场" || blocking != nil {
|
||||
t.Fatalf("inner 的硬停被改写: prevent=%v blocking=%v msg=%q", prevent, blocking, msg)
|
||||
}
|
||||
if n := h.nudges.Load(); n != 0 {
|
||||
t.Fatalf("让位给 inner 时不应消耗配额, got %d", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("inner 已要续跑时不叠加", func(t *testing.T) {
|
||||
h := steerHooks{inner: fakeHooks{blocking: []string{"guard 的续跑理由"}}, nudges: &atomic.Int64{}, limit: defaultEmptyTurnNudges}
|
||||
_, blocking, _ := h.Stop(context.Background(), empty)
|
||||
if len(blocking) != 1 || blocking[0] != "guard 的续跑理由" {
|
||||
t.Fatalf("inner 的续跑消息被改写: %v", blocking)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("未装计数器时行为不变", func(t *testing.T) {
|
||||
h := steerHooks{limit: defaultEmptyTurnNudges} // 例如未来其他调用点忘了传 nudges
|
||||
if _, blocking, _ := h.Stop(context.Background(), empty); blocking != nil {
|
||||
t.Fatalf("无计数器时不应注入: %v", blocking)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -78,3 +78,29 @@ func (e *Engine) modelErrorRetryPolicy() (retries int, backoff time.Duration) {
|
||||
}
|
||||
return retries, backoff
|
||||
}
|
||||
|
||||
// emptyTurnNudgeLimit resolves how many empty-turn continuations one work may
|
||||
// inject (see steerHooks.Stop). It deliberately reuses layer ②'s knob —— 「空响应
|
||||
// 重试次数」:两者是同一件事的两种手段。SDK 那层管「一个内容块都没有」,手段是把
|
||||
// 同一个请求原样重发;这里管「只有思考、既无正文也无工具」,手段是追加一条指令让
|
||||
// 模型带着已有思考接着做(原样重发对这种由上下文形状决定的空转没有意义)。判空口径
|
||||
// 不同是因为 SDK 以「有没有 yield 过事件」为准,而思考增量本身就是事件——但用户配
|
||||
// 「空响应重试几次」时想表达的是「模型没产出实质内容就再来一次」,两层共用一个次数
|
||||
// 才对得上这个心智。
|
||||
//
|
||||
// 读全局策略而不是某个 profile 的覆盖:一个 run 中途可能因故障转移换 profile,而这
|
||||
// 是整条意图的总量闸,不该跟着换端点而变。语义与 SDK 的 emptyRetries() 同构:
|
||||
// 0 = 默认 defaultEmptyTurnNudges;-1(负) = 关闭空转续跑;>0 = 用该值。
|
||||
func (e *Engine) emptyTurnNudgeLimit() int {
|
||||
if e == nil || e.m == nil || e.m.pg == nil {
|
||||
return defaultEmptyTurnNudges
|
||||
}
|
||||
switch n := e.m.pg.LLMRetryPolicy().Empty.Attempts; {
|
||||
case n == 0:
|
||||
return defaultEmptyTurnNudges
|
||||
case n < 0:
|
||||
return 0
|
||||
default:
|
||||
return n
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user