@@ -6,20 +6,27 @@ package agent
66import (
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。
1517const defaultMaxRounds = 3
1618
19+ // maxParallelTools 是单轮内并发执行工具调用的上限:模型一轮发多个工具调用时并发跑(多为 I/O
20+ // 密集的检索),削减串行延迟,同时 clamp 住并发度避免突发打满下游。
21+ const maxParallelTools = 4
22+
1723// defaultRunStrings 是 RunStrings 各字段留空时的回退(保持历史中文行为,向后兼容)。
1824var 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 推远导致配对失败)。
182205func 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。
235320const toolImageNote = "(以下是上一步检索命中的 Echo 的配图,供你结合图片内容作答)"
236321
0 commit comments