From 9ffdebbb0b77d822bda14c4f327e5cc3e792eb70 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 27 Oct 2026 15:53:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(agent):=20=E7=BB=AD=E5=86=99=E5=8D=A0?= =?UTF-8?q?=E4=BD=8D=E4=B8=8D=E5=86=8D=E6=8A=8A=E3=80=8C=E6=89=BF=E8=AF=BA?= =?UTF-8?q?=E5=90=8E=E6=B2=A1=E5=81=9A=E7=9A=84=E4=BA=8B=E3=80=8D=E4=B8=80?= =?UTF-8?q?=E5=B9=B6=E5=8A=9D=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现场(用户原话:"那你去啊?说完就停了"):模型经 output_send__qq 说了一句 "我这就去办",内核判定本批全是输出通道发送(ReplyOnly)后补的占位只有终止许可 ——"若你的回复已完成,直接返回纯文本即可结束本轮,无需再调用任何工具"—— 模型照做返回纯文本,本轮结束,活没干。 但这条护栏不能简单删掉:注释里记着一次事故,通用「请继续」被读成 "再发一条消息",生产实测单轮 34 次 output_send__qq、持续 514 秒。 改法:把两种授权分开说清楚,护栏不削弱。 - 没做完的操作 → 现在继续做完(继续指**非发送类**工具) - 真的做完了 → 返回纯文本收尾,并明确「不要再发第二条消息」 判据:continuation_placeholder_test.go(必须同时含"继续做完"与 "不要再发第二条消息";不得再出现"无需再调用任何工具") --- .../core/continuation_placeholder_test.go | 42 +++++++++++++++++++ internal/agent/core/process.go | 14 ++++++- 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 internal/agent/core/continuation_placeholder_test.go diff --git a/internal/agent/core/continuation_placeholder_test.go b/internal/agent/core/continuation_placeholder_test.go new file mode 100644 index 00000000..889ff479 --- /dev/null +++ b/internal/agent/core/continuation_placeholder_test.go @@ -0,0 +1,42 @@ +package core + +// 「本批全是输出通道发送」之后补的占位文案必须同时给出**两种授权**。 +// +// 现场(2026-10-27,用户原话:"那你去啊?说完就停了"): +// 模型经 output_send__qq 说了一句"我这就去办",内核随后补的占位只有 +// 终止许可("若你的回复已完成,直接返回纯文本即可结束本轮")⇒ 模型照做, +// 本轮结束,活没干。这不是模型偷懒,是内核把它劝退了。 +// +// 但也不能只写"继续":注释里记着一次事故 —— 通用「请继续」被读成 +// "再发一条消息",生产实测单轮 34 次 output_send__qq、持续 514 秒。 +// +// 所以判据是两条同时成立: +// ① 明确"没做完就继续做"(允许继续调工具) +// ② 明确"全部完成就收尾,且不要再发第二条消息"(护栏不削弱) + +import ( + "strings" + "testing" +) + +func TestReplyDeliveredPlaceholderAllowsBothContinuingAndStopping(t *testing.T) { + p := replyDeliveredPlaceholder + if !strings.Contains(p, "没做完") || !strings.Contains(p, "继续做完") { + t.Fatalf("占位文案必须允许「未做完就继续」,实际: %q", p) + } + if !strings.Contains(p, "结束本轮") { + t.Fatalf("占位文案必须保留终止许可(否则模型会无限发送),实际: %q", p) + } + if !strings.Contains(p, "不要再发第二条消息") { + t.Fatalf("占位文案必须明确禁止重复发送(防 34 连发事故复发),实际: %q", p) + } + if strings.Contains(p, "无需再调用任何工具") { + t.Fatalf("不得出现「无需再调用任何工具」——那会把「承诺后未执行」也一并劝退,实际: %q", p) + } + if continuationFor(true) != p { + t.Fatal("replyOnly 时必须使用本条占位") + } + if continuationFor(false) != continuationPlaceholder { + t.Fatal("非 replyOnly 时必须用通用续写占位") + } +} diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go index 8285b44c..30f95252 100644 --- a/internal/agent/core/process.go +++ b/internal/agent/core/process.go @@ -34,8 +34,18 @@ const continuationPlaceholder = "请根据以上工具结果继续。" // 还是再发一条消息。两者叠加成自我强化的发送循环:生产实测单轮 34 次 // output_send__qq、持续 514 秒,直到 QQ 插件自己的循环保险拒绝发送才停下。 // -// 所以这里换成一条明确的终止许可:已回复完就直接返回纯文本收尾。 -const replyDeliveredPlaceholder = "若你的回复已完成,直接返回纯文本即可结束本轮,无需再调用任何工具。" +// 所以这里必须保留「终止许可」,否则发送风暴回来。 +// +// ★ 但原措辞只有终止、没有"继续干活"的授权(2026-10-27 现场): +// 模型先说一句"我这就去办"(经 output_send 交付),随后被这条占位劝退 +// ⇒ 活没干就结束本轮。用户看到的正是"说完就停了"。 +// +// 现在把两件事分开说: +// +// · 有没做完的操作 → 现在继续做完(继续指**非发送类**工具) +// · 真的做完了 → 返回纯文本收尾,**不要再发第二条消息**(护栏仍在) +const replyDeliveredPlaceholder = "回复已发送。若还有没做完的操作(需要调用工具才能完成的)," + + "现在继续做完;若这一轮的工作已经全部完成,直接返回纯文本结束本轮——不要再发第二条消息。" // continuationFor 选择工具轮之后补位的 user 占位文案。 // replyOnly 表示上一批工具调用全部是输出通道发送(即模型刚交付了回复)。