Skip to content

Commit 030d5f9

Browse files
lin-snowclaude
andcommitted
feat(copilot): Agent 优化七件套 + 检索按作者收口
Agent/Copilot 优化(已采纳范围): - P0 时区:Chat 全程按 X-Timezone 算"今天"、解析 date_from/date_to 与渲染日期,修跨 UTC 日界归属偏一天;站点级近况总结仍走 UTC。 - P0 prompt cache:Anthropic 最后一个 system 块打 cache_control 断点, 缓存 tools+system 静态前缀,抵消 ReAct 循环重发成本;OpenAI 端服务端自动缓存。 - P1 top_k 放宽:limit 入参 + effectiveTopK 随上下文窗口缩放(默认 6/大窗 10,上限 20)。 - P1 maxRounds 可配:ECH0_AGENT_MAX_ROUNDS(默认 4)。 - P1 新工具 stats_overview:纯内存聚合给精确量化(总条数/活跃天/按月/最活跃月/Top标签)。 - P3 工具并发:execTools 三段式(顺序去重 → errgroup 有界并发 → 顺序收尾), 并修 Anthropic 多 tool_result 配对隐患(先聚 tool_result 再追加图片消息)。 - P3 轮内 token 预算:MaxContextTokens 超限时回收最旧工具结果(替换为占位、不删消息)。 多用户隔离(检索收口本人): - embedding Search 增 authorUsername:over-fetch 后按 username 过滤再裁到 k。 - EchoQueryDto.UserID(json:"-",opt-in)按 echos.user_id 精确过滤。 - Copilot 注入 UserReader(wire 跨域绑定 user 服务),Chat 检索收口当前对话用户。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e387773 commit 030d5f9

28 files changed

Lines changed: 815 additions & 138 deletions

internal/agent/provider_anthropic.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ func (p *anthropicProvider) buildParams(req Request) anthropic.MessageNewParams
5353
Messages: msgs,
5454
}
5555
if len(systemBlocks) > 0 {
56+
// Prompt cache:在最后一个 system 块打 ephemeral 断点。Anthropic 缓存顺序为
57+
// tools → system → messages,故此断点会把 tool 定义 + system 整段静态前缀一并缓存,
58+
// 工具循环里 round 2/3 与短时连续请求即命中缓存,抵消 ReAct 多轮的 token 成本。
59+
systemBlocks[len(systemBlocks)-1].CacheControl = anthropic.NewCacheControlEphemeralParam()
5660
params.System = systemBlocks
5761
}
5862
if tools := p.buildTools(req.Tools); len(tools) > 0 {

internal/agent/provider_openai.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ import (
1515
)
1616

1717
// openaiProvider 适配 OpenAI 兼容协议(OpenAI / DeepSeek / Qwen / Moonshot / Ollama 等)。
18+
//
19+
// Prompt cache:OpenAI 兼容端是服务端自动缓存(前缀 >1024 token 自动命中),无客户端字段可设,
20+
// 故无需像 Anthropic 那样显式打 cache_control 断点——工具循环里重复的 system+工具定义前缀会被
21+
// 服务端自动复用。
1822
type openaiProvider struct {
1923
setting model.AgentSetting
2024
}

internal/agent/run.go

Lines changed: 110 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,27 @@ package agent
66
import (
77
"context"
88
"strings"
9+
"unicode/utf8"
910

1011
logUtil "github.com/lin-snow/ech0/internal/util/log"
1112
"go.uber.org/zap"
13+
"golang.org/x/sync/errgroup"
1214
)
1315

1416
// defaultMaxRounds 是工具轮数上限护栏:防模型反复调工具死循环烧 token。
1517
const defaultMaxRounds = 3
1618

19+
// maxParallelTools 是单轮内并发执行工具调用的上限:模型一轮发多个工具调用时并发跑(多为 I/O
20+
// 密集的检索),削减串行延迟,同时 clamp 住并发度避免突发打满下游。
21+
const maxParallelTools = 4
22+
1723
// defaultRunStrings 是 RunStrings 各字段留空时的回退(保持历史中文行为,向后兼容)。
1824
var defaultRunStrings = RunStrings{
19-
DedupNote: "(已检索过,结果见上)",
20-
UnknownTool: "未知工具:",
21-
ToolError: "工具执行失败:",
22-
ImageNote: toolImageNote,
25+
DedupNote: "(已检索过,结果见上)",
26+
UnknownTool: "未知工具:",
27+
ToolError: "工具执行失败:",
28+
ImageNote: toolImageNote,
29+
ContextTrimNote: "(早前检索结果已省略以控制长度)",
2330
}
2431

2532
// withDefaults 用 defaultRunStrings 填充留空字段。
@@ -36,6 +43,9 @@ func (s RunStrings) withDefaults() RunStrings {
3643
if s.ImageNote == "" {
3744
s.ImageNote = defaultRunStrings.ImageNote
3845
}
46+
if s.ContextTrimNote == "" {
47+
s.ContextTrimNote = defaultRunStrings.ContextTrimNote
48+
}
3949
return s
4050
}
4151

@@ -90,6 +100,8 @@ func runLoop(ctx context.Context, provider Provider, req RunRequest, out chan<-
90100
strs := req.Strings.withDefaults()
91101

92102
for round := 0; round < maxRounds; round++ {
103+
// 轮内 token 预算回收:超限时把最旧的工具结果替换为占位,防多轮累积撑爆窗口。
104+
trimContext(messages, req.MaxContextTokens, strs.ContextTrimNote)
93105
o := streamRound(ctx, provider, out, messages, toolDefs, req.Temp)
94106
if o.aborted {
95107
return // ctx 取消
@@ -112,6 +124,7 @@ func runLoop(ctx context.Context, provider Provider, req RunRequest, out chan<-
112124
}
113125

114126
// 工具轮用尽仍在调工具:强制一轮「不给工具」让模型据已检索到的结果作答,保证有回答。
127+
trimContext(messages, req.MaxContextTokens, strs.ContextTrimNote)
115128
o := streamRound(ctx, provider, out, messages, nil, req.Temp)
116129
if o.aborted {
117130
return
@@ -177,8 +190,18 @@ func streamRound(
177190
return o
178191
}
179192

180-
// execTools 顺序执行一轮的工具调用:去重、emit Searching/ToolResult、把结果追加进 messages。
193+
// execTools 执行一轮的工具调用:去重、emit Searching/ToolResult、把结果追加进 messages。
181194
// 工具执行错误不中止(回喂模型自愈);仅 ctx 取消时返回 false。
195+
//
196+
// 三段式(保留消息顺序、去重确定性,同时并发掉 I/O 密集的执行):
197+
//
198+
// A. 顺序预处理——去重命中 / 未知工具就地定好其 tool 结果消息,其余记为待执行;
199+
// B. 有界并发执行待执行项(emit Searching + Execute),结果按 index 写入各自槽,无竞态;
200+
// C. 顺序收尾——按调用原序 emit ToolResult、定好 tool 结果消息与(可选)带图消息。
201+
//
202+
// 追加顺序:**先把全部 tool 结果消息按原序追加,再追加带图 user 消息**。这样一轮 assistant 的
203+
// 多个 tool_use 的 tool_result 紧邻聚合,满足 Anthropic「tool_result 必须在紧随的同一条 user
204+
// 消息里与 tool_use 一一对应」的约束(旧逐条「结果→图→结果→图」会把后续 result 推远导致配对失败)。
182205
func execTools(
183206
ctx context.Context,
184207
out chan<- AgentEvent,
@@ -188,49 +211,111 @@ func execTools(
188211
messages *[]Message,
189212
strs RunStrings,
190213
) bool {
191-
for _, tc := range calls {
214+
n := len(calls)
215+
toolMsgs := make([]Message, n) // 每个调用对应的 tool 结果消息(含去重/未知/错误/正常)
216+
imageMsgs := make([]*Message, n) // 每个调用可选的带图 user 消息(多模态)
217+
outputs := make([]ToolOutput, n)
218+
execErrs := make([]error, n)
219+
220+
// A. 顺序预处理:去重与未知工具就地定好结果消息;其余记为待执行(保留原序 index)。
221+
var runnable []int
222+
for i, tc := range calls {
192223
key := tc.Name + ":" + string(tc.Args)
193224
if seen[key] {
194-
*messages = append(*messages, Message{Role: RoleTool, ToolCallID: tc.ID, Content: strs.DedupNote})
225+
toolMsgs[i] = Message{Role: RoleTool, ToolCallID: tc.ID, Content: strs.DedupNote}
195226
continue
196227
}
197228
seen[key] = true
198-
199-
tool, ok := toolByName[tc.Name]
200-
if !ok {
201-
*messages = append(*messages, Message{Role: RoleTool, ToolCallID: tc.ID, Content: strs.UnknownTool + tc.Name})
229+
if _, ok := toolByName[tc.Name]; !ok {
230+
toolMsgs[i] = Message{Role: RoleTool, ToolCallID: tc.ID, Content: strs.UnknownTool + tc.Name}
202231
continue
203232
}
233+
runnable = append(runnable, i)
234+
}
204235

205-
if !emit(ctx, out, AgentEvent{Kind: AgentSearching, ToolName: tc.Name, ToolArgs: tc.Args}) {
206-
return false
207-
}
236+
// B. 有界并发执行:每个 goroutine emit Searching + Execute,结果写入独立 index 槽。
237+
// emit 失败(ctx 取消)→ 返回 ctx.Err() 让整组取消。g.Wait 阻塞至所有 goroutine 结束,
238+
// 故 outputs/execErrs 的写入在 Wait 返回前全部完成,后续顺序读取无竞态。
239+
var g errgroup.Group
240+
g.SetLimit(maxParallelTools)
241+
for _, idx := range runnable {
242+
idx, tc, tool := idx, calls[idx], toolByName[calls[idx].Name]
243+
g.Go(func() error {
244+
if !emit(ctx, out, AgentEvent{Kind: AgentSearching, ToolName: tc.Name, ToolArgs: tc.Args}) {
245+
return ctx.Err()
246+
}
247+
outputs[idx], execErrs[idx] = tool.Execute(ctx, tc.Args)
248+
return nil
249+
})
250+
}
251+
if err := g.Wait(); err != nil {
252+
return false // ctx 取消
253+
}
208254

209-
output, execErr := tool.Execute(ctx, tc.Args)
210-
if execErr != nil {
255+
// C. 顺序收尾:按原序 emit ToolResult、定好结果/带图消息。
256+
for _, idx := range runnable {
257+
tc := calls[idx]
258+
if execErrs[idx] != nil {
211259
logUtil.GetLogger().Warn("agent tool execute failed",
212260
zap.String("module", "agent"),
213261
zap.String("tool", tc.Name),
214-
zap.Error(execErr))
215-
*messages = append(*messages, Message{Role: RoleTool, ToolCallID: tc.ID, Content: strs.ToolError + execErr.Error()})
262+
zap.Error(execErrs[idx]))
263+
toolMsgs[idx] = Message{Role: RoleTool, ToolCallID: tc.ID, Content: strs.ToolError + execErrs[idx].Error()}
216264
continue
217265
}
218-
219-
if !emit(ctx, out, AgentEvent{Kind: AgentToolResult, ToolName: tc.Name, Meta: output.Meta}) {
266+
if !emit(ctx, out, AgentEvent{Kind: AgentToolResult, ToolName: tc.Name, Meta: outputs[idx].Meta}) {
220267
return false
221268
}
222-
*messages = append(*messages, Message{Role: RoleTool, ToolCallID: tc.ID, Content: output.Content})
223-
224-
// 多模态:工具带出了图片(如命中 Echo 的配图)→ 紧跟一条带图 user 消息递给模型。
269+
toolMsgs[idx] = Message{Role: RoleTool, ToolCallID: tc.ID, Content: outputs[idx].Content}
270+
// 多模态:工具带出了图片(如命中 Echo 的配图)→ 用带图 user 消息递给模型。
225271
// 走 user 消息而非塞进 tool_result,是因 OpenAI 的 tool 角色消息只能纯文本,
226272
// user 带图两家协议都支持,一套逻辑通用。
227-
if len(output.Images) > 0 {
228-
*messages = append(*messages, Message{Role: RoleUser, Content: strs.ImageNote, Images: output.Images})
273+
if len(outputs[idx].Images) > 0 {
274+
imageMsgs[idx] = &Message{Role: RoleUser, Content: strs.ImageNote, Images: outputs[idx].Images}
275+
}
276+
}
277+
278+
// 先追加全部 tool 结果(聚合相邻,满足 Anthropic 配对约束),再追加带图消息。
279+
*messages = append(*messages, toolMsgs...)
280+
for i := range imageMsgs {
281+
if imageMsgs[i] != nil {
282+
*messages = append(*messages, *imageMsgs[i])
229283
}
230284
}
231285
return true
232286
}
233287

288+
// trimContext 在轮内消息上下文超 budget 时回收最旧的工具结果:把其 Content 替换为 note 占位
289+
// (保留消息与 ToolCallID 配对,绝不删消息——否则 tool_use/tool_result 失配会被 API 400)。
290+
// budget<=0 时不回收。逐条替换直到回到预算内或没有可回收的工具结果。
291+
func trimContext(messages []Message, budget int, note string) {
292+
if budget <= 0 {
293+
return
294+
}
295+
for contextTokens(messages) > budget {
296+
idx := -1
297+
for i := range messages {
298+
if messages[i].Role == RoleTool && messages[i].Content != note {
299+
idx = i
300+
break
301+
}
302+
}
303+
if idx < 0 {
304+
return // 没有可回收的工具结果了
305+
}
306+
messages[idx].Content = note
307+
}
308+
}
309+
310+
// contextTokens 估算消息上下文的 token 总量(仅按文本 rune 计,图片不计)。
311+
func contextTokens(messages []Message) int {
312+
total := 0
313+
for i := range messages {
314+
total += utf8.RuneCountInString(messages[i].Content)
315+
}
316+
return total
317+
}
318+
234319
// toolImageNote 是带图 user 消息的说明文本,告诉模型这些图来自上一步检索命中的 Echo。
235320
const toolImageNote = "(以下是上一步检索命中的 Echo 的配图,供你结合图片内容作答)"
236321

internal/agent/run_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"context"
88
"encoding/json"
99
"errors"
10+
"strings"
1011
"testing"
1112
"time"
1213

@@ -343,6 +344,78 @@ func TestRunLoop_CustomImageNote(t *testing.T) {
343344
}
344345
}
345346

347+
// 单轮多工具:模型一轮内发起两个不同工具调用,二者都执行(并发),且下一轮上下文里两条
348+
// tool 结果按调用原序回灌;Searching/ToolResult 各 2 次。-race 下应无竞态。
349+
func TestRunLoop_ParallelToolsSingleRound(t *testing.T) {
350+
toolA := Tool{
351+
Def: ToolDef{Name: "tool_a", Description: "a", Parameters: json.RawMessage(`{"type":"object"}`)},
352+
Execute: func(_ context.Context, _ json.RawMessage) (ToolOutput, error) { return ToolOutput{Content: "AAA"}, nil },
353+
}
354+
toolB := Tool{
355+
Def: ToolDef{Name: "tool_b", Description: "b", Parameters: json.RawMessage(`{"type":"object"}`)},
356+
Execute: func(_ context.Context, _ json.RawMessage) (ToolOutput, error) { return ToolOutput{Content: "BBB"}, nil },
357+
}
358+
fp := &fakeProvider{scripts: [][]Event{
359+
{toolCallEvent("c1", "tool_a", `{}`), toolCallEvent("c2", "tool_b", `{}`), doneEvent()},
360+
{textEvent("answer"), doneEvent()},
361+
}}
362+
363+
evs := runLoopSync(context.Background(), fp, RunRequest{Setting: enabledSetting(), Tools: []Tool{toolA, toolB}})
364+
365+
if n := countKind(evs, AgentSearching); n != 2 {
366+
t.Fatalf("AgentSearching count = %d, want 2", n)
367+
}
368+
if n := countKind(evs, AgentToolResult); n != 2 {
369+
t.Fatalf("AgentToolResult count = %d, want 2", n)
370+
}
371+
372+
// 下一轮 Messages 里两条 RoleTool 必须按调用原序:c1(AAA) 在 c2(BBB) 之前。
373+
var toolMsgs []Message
374+
for _, m := range fp.gotReqs[1].Messages {
375+
if m.Role == RoleTool {
376+
toolMsgs = append(toolMsgs, m)
377+
}
378+
}
379+
if len(toolMsgs) != 2 {
380+
t.Fatalf("next round should carry 2 tool results, got %d", len(toolMsgs))
381+
}
382+
if toolMsgs[0].ToolCallID != "c1" || toolMsgs[0].Content != "AAA" {
383+
t.Fatalf("first tool result = %+v, want c1/AAA", toolMsgs[0])
384+
}
385+
if toolMsgs[1].ToolCallID != "c2" || toolMsgs[1].Content != "BBB" {
386+
t.Fatalf("second tool result = %+v, want c2/BBB", toolMsgs[1])
387+
}
388+
}
389+
390+
// 轮内 token 预算回收:超 MaxContextTokens 时,最旧的 RoleTool 结果被替换为占位,较新的保留。
391+
func TestRunLoop_TrimsOldestToolResultOverBudget(t *testing.T) {
392+
note := defaultRunStrings.ContextTrimNote
393+
msgs := []Message{
394+
{Role: RoleSystem, Content: "S"},
395+
{Role: RoleUser, Content: "Q"},
396+
{Role: RoleAssistant, ToolCalls: []ToolCall{{ID: "t1", Name: "search_echos"}}},
397+
{Role: RoleTool, ToolCallID: "t1", Content: strings.Repeat("a", 100)}, // 最旧、最大
398+
{Role: RoleAssistant, ToolCalls: []ToolCall{{ID: "t2", Name: "search_echos"}}},
399+
{Role: RoleTool, ToolCallID: "t2", Content: strings.Repeat("b", 20)}, // 较新
400+
}
401+
// 预算 50:替换最旧的 t1(100→note 长度) 后 1+1+len(note)+20 应 ≤ 50。
402+
fp := &fakeProvider{scripts: [][]Event{{textEvent("ok"), doneEvent()}}}
403+
404+
runLoopSync(context.Background(), fp, RunRequest{
405+
Setting: enabledSetting(),
406+
Messages: msgs,
407+
MaxContextTokens: 50,
408+
})
409+
410+
got := fp.gotReqs[0].Messages
411+
if got[3].Content != note {
412+
t.Fatalf("oldest tool result should be trimmed to note, got %q", got[3].Content)
413+
}
414+
if got[5].Content != strings.Repeat("b", 20) {
415+
t.Fatalf("recent tool result should be preserved, got %q", got[5].Content)
416+
}
417+
}
418+
346419
// 多模态:工具带出图片时,下一轮 Messages 应追加一条带图的 RoleUser(Content==toolImageNote)。
347420
func TestRunLoop_ToolImageNoteAppended(t *testing.T) {
348421
img := ImagePart{MediaType: "image/png", Base64: "abc"}

internal/agent/types.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,11 @@ type Event struct {
104104
// RunStrings 是 Loop 在工具循环中回喂给模型 / 注入消息的少量提示文案。由领域层(知道 locale)
105105
// 注入,使 agent 包保持 i18n 零依赖;任一字段留空则回退到 defaultRunStrings(中文,保持历史行为)。
106106
type RunStrings struct {
107-
DedupNote string // 同一查询重复调用时整条 tool 结果的内容
108-
UnknownTool string // 未知工具名提示的前缀(后接工具名)
109-
ToolError string // 工具执行失败提示的前缀(后接错误信息)
110-
ImageNote string // 带图 user 消息的说明文本
107+
DedupNote string // 同一查询重复调用时整条 tool 结果的内容
108+
UnknownTool string // 未知工具名提示的前缀(后接工具名)
109+
ToolError string // 工具执行失败提示的前缀(后接错误信息)
110+
ImageNote string // 带图 user 消息的说明文本
111+
ContextTrimNote string // 轮内 token 预算回收时,替换最旧工具结果内容的占位文案
111112
}
112113

113114
// RunRequest 是 Loop 层对领域层(Copilot Service)暴露的请求。
@@ -119,6 +120,9 @@ type RunRequest struct {
119120
Temp *float32 // nil → 不设置
120121
Strings RunStrings // 回喂/注入文案;零值字段回退到 defaultRunStrings
121122
Timeout time.Duration // 单轮运行(含工具循环)整体超时;<=0 → 不额外设超时(沿用传入 ctx)
123+
// MaxContextTokens 是工具循环里整轮消息上下文的软上限(估算 token);>0 时超限即回收
124+
// 最旧的工具结果(替换为 Strings.ContextTrimNote),防多轮工具结果累积撑爆窗口。0 → 不回收。
125+
MaxContextTokens int
122126
}
123127

124128
// AgentEventKind 区分 Loop 上浮给领域层的语义事件类型。

internal/config/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,9 @@ type MigrationConfig struct {
175175
type AgentConfig struct {
176176
// TimeoutSeconds 是单轮 Agent 运行(含整个工具循环)的整体超时,单位秒;<=0 表示不额外设超时。
177177
TimeoutSeconds int `env:"ECH0_AGENT_TIMEOUT_SECONDS"`
178+
// MaxRounds 是 Chat 单轮问答内的工具调用轮数上限(ReAct 护栏),防模型反复调工具烧 token;
179+
// <=0 时 agent 包回退内置默认。
180+
MaxRounds int `env:"ECH0_AGENT_MAX_ROUNDS"`
178181
}
179182

180183
// Config 返回全局配置中心
@@ -311,6 +314,7 @@ func defaultConfig() *AppConfig {
311314
},
312315
Agent: AgentConfig{
313316
TimeoutSeconds: 120,
317+
MaxRounds: 4,
314318
},
315319
}
316320
}

internal/di/wire.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import (
2424
"github.com/lin-snow/ech0/internal/server"
2525
"github.com/lin-snow/ech0/internal/service"
2626
commentService "github.com/lin-snow/ech0/internal/service/comment"
27+
copilotService "github.com/lin-snow/ech0/internal/service/copilot"
28+
userService "github.com/lin-snow/ech0/internal/service/user"
2729
"github.com/lin-snow/ech0/internal/storage"
2830
"github.com/lin-snow/ech0/internal/task"
2931
"github.com/lin-snow/ech0/internal/transaction"
@@ -131,6 +133,8 @@ var HandlerSet = wire.NewSet(
131133
handler.EmbeddingSet,
132134

133135
service.CopilotSet,
136+
// Copilot 的 UserReader 跨域绑定到 user 服务(取当前对话用户:展示名 + 检索按作者收口)。
137+
wire.Bind(new(copilotService.UserReader), new(*userService.UserService)),
134138
handler.CopilotSet,
135139

136140
service.BackupSet,

0 commit comments

Comments
 (0)