- 设置 RetryConfig.Timeout = 120s:确保每次 LLM 重试有独立的 120 秒超时窗口
- Engine 切换流式 LLM 调用:将 engine.go 的
Generate()改为GenerateStream(),收集 stream events 组装为LLMResponse,过程中实时更新进度
DefaultRetryConfig()中Timeout字段当前为 0(不设超时)perAttemptCtx在 Timeout=0 时会尝试从父 ctx deadline 推导,父 ctx 也无 deadline 则不设超时- 需要:DefaultRetryConfig 设置 Timeout=120s,同时支持通过环境变量覆盖
| 文件 | 改动 |
|---|---|
llm/retry.go | DefaultRetryConfig() 增加 Timeout: 120 * time.Second |
config/config.go | 新增 LLMRetryTimeout time.Duration 配置项 |
config/config.go | Default() 增加默认值 120s + 环境变量 LLM_RETRY_TIMEOUT |
main.go | createLLM 调用处传入 Timeout: cfg.Agent.LLMRetryTimeout |
llm/retry_test.go | 更新 TestDefaultRetryConfig 断言 |
当前 engine.go 调用 cfg.LLMClient.Generate(),等待完整响应后才返回。
流式方案需要:
- 检查 LLMClient 是否实现
StreamingLLM接口 - 调用
GenerateStream()获取<-chan StreamEvent - 遍历 events 收集 content、tool_calls、usage,组装为
LLMResponse - 遍历过程中,每收到一段 content 就实时更新进度(可选,不影响功能)
Q: 是否需要回退到非流式?
是的。如果 LLMClient 不实现 StreamingLLM(如某些自定义实现),回退到 Generate()。
Q: 流式收集逻辑放哪里?
新增 llm/stream.go,提供 CollectStream(ctx, eventCh) 函数,将 <-chan StreamEvent 转为 (*LLMResponse, error)。可被 engine 复用,也可被其他调用方复用。
Q: 进度展示?
流式过程中,每收到一批 content delta,更新进度行:> 💭 正在思考... → > 💭 正在思考: [前100字摘要]...
收到 tool_call 时显示 > 🔧 调用工具: Read, Shell, ...
| 文件 | 改动 |
|---|---|
llm/stream.go | 新增 CollectStream(ctx, <-chan StreamEvent) (*LLMResponse, error) |
llm/stream_test.go | 新增测试 |
agent/engine.go | LLM 调用处:先尝试 GenerateStream + CollectStream,回退 Generate |
func CollectStream(ctx context.Context, eventCh <-chan StreamEvent) (*LLMResponse, error) {
var resp LLMResponse
var content strings.Builder
var reasoningContent strings.Builder
toolCalls := make(map[int]*ToolCallDelta) // index → accumulated delta
for ev := range eventCh {
switch ev.Type {
case EventContent:
content.WriteString(ev.Content)
case EventReasoningContent:
reasoningContent.WriteString(ev.ReasoningContent)
case EventToolCall:
tc := toolCalls[ev.ToolCall.Index]
if tc == nil {
tc = &ToolCallDelta{Index: ev.ToolCall.Index}
toolCalls[ev.ToolCall.Index] = tc
}
tc.ID = firstNonEmpty(tc.ID, ev.ToolCall.ID)
tc.Name = firstNonEmpty(tc.Name, ev.ToolCall.Name)
tc.Arguments += ev.ToolCall.Arguments
case EventUsage:
resp.Usage = *ev.Usage
case EventDone:
resp.FinishReason = ev.FinishReason
case EventError:
return nil, fmt.Errorf("stream error: %s", ev.Error)
}
}
resp.Content = content.String()
resp.ReasoningContent = reasoningContent.String()
// 将 map 转为有序 slice
for i := 0; i < len(toolCalls); i++ {
if tc, ok := toolCalls[i]; ok {
resp.ToolCalls = append(resp.ToolCalls, ToolCall{
ID: tc.ID, Name: tc.Name, Arguments: tc.Arguments,
})
}
}
return &resp, nil
}
// engine.go Run() 主循环中的 LLM 调用
var response *llm.LLMResponse
if streaming, ok := cfg.LLMClient.(llm.StreamingLLM); ok {
eventCh, streamErr := streaming.GenerateStream(retryNotifyCtx, cfg.Model, messages, toolDefs, cfg.ThinkingMode)
if streamErr != nil {
err = streamErr
} else {
response, err = llm.CollectStream(ctx, eventCh)
}
} else {
response, err = cfg.LLMClient.Generate(retryNotifyCtx, cfg.Model, messages, toolDefs, cfg.ThinkingMode)
}
同理,input-too-long 重试路径也做相同改造。
- 不做实时进度推送:流式过程中不频繁 notifyProgress,因为 LLM 响应通常 10-60 秒,粒度太细反而刷屏。现有的 “💭 思考中…” 占位已经足够。后续可以在流式过程中逐步更新 thinking content 作为增强。
- 不改 RunConfig.LLMClient 类型:保持
llm.LLM接口,运行时 type assert 检查 StreamingLLM
llm/retry.go: DefaultRetryConfig 增加 Timeout=120sllm/stream.go+llm/stream_test.go: CollectStream 实现 + 测试config/config.go+main.go: LLMRetryTimeout 配置agent/engine.go: LLM 调用切换到流式llm/retry_test.go: 更新测试断言go build ./...+go test ./...验证