核心概念:
- 用户输入 (User Input): 整个流程的起点。
- 规划器 (Planner - LLM 1, 例如 Claude 3.7): 接收用户提示 (prompt),并将其分解为一系列子任务。理想情况下,每个子任务都应指定使用哪个工具以及该工具需要什么输入。
- 调度器/执行器 (Scheduler/Executor - LLM 2, 例如 Qwen,或者也可以是基于规则的):
- 管理任务列表 (待办事项)。
- 决定接下来执行哪个任务。
- 如果任务需要工具,它会准备该工具的输入并调用它。
- 接收来自工具的输出。
- 根据工具的输出更新任务状态,并可能更新整体计划。
- 一旦所有必要的任务完成,就为用户综合生成最终答案。
- 工具 (Tools): 执行特定操作的函数。这里我们有一个"联网搜索"工具。
- OpenAPI 客户端 (OpenAPI Clients): 用于向 LLM API 发出 HTTP 请求的 Go 代码。
Go 中的高级工作流程:
rust
用户提示 -> 规划器 (LLM1) -> 结构化计划 (任务列表)
|
V
调度器 (LLM2 / 基于规则) ----- (循环)
| 1. 从计划中选择下一个任务
| 2. 如果任务需要工具:
| | a. 准备工具输入
| | b. 调用工具 (例如 WebSearch) -> 工具输出
| | c. 将工具输出提供给调度器 (LLM2) 进行处理/下一步决策
| 3. 如果任务不需要工具 (例如,由 LLM2 直接进行总结、推理):
| | a. LLM2 执行任务
| 4. LLM2 更新计划,判断目标是否达成,或确定下一个任务。
| (当目标达成时结束循环)
V
最终答案 -> 用户
Go 项目结构 (简化版):
go
/my-agent-app
/cmd
/main.go // 主应用程序入口点
/pkg
/llmclient // 与 LLM API (Claude, Qwen) 交互的客户端
llmclient.go
/planner // 规划阶段的逻辑
planner.go
/scheduler // 调度和执行的逻辑
scheduler.go
/tools // 工具的定义和实现
tools.go
websearch.go
/config // 配置 (API 密钥等)
config.go
go.mod
go.sum
分步实现细节:
1. pkg/config/config.go
go
package config
import (
"log"
"os"
"github.com/joho/godotenv"
)
type Config struct {
ClaudeAPIKey string
ClaudeAPIURL string
QwenAPIKey string
QwenAPIURL string
// 如果需要,添加其他搜索 API 密钥,例如 SerpAPI
SerpAPIKey string
}
var AppConfig Config
func LoadConfig() {
// 如果存在 .env 文件,则加载它
err := godotenv.Load()
if err != nil {
log.Println("未找到 .env 文件,将依赖环境变量")
}
AppConfig = Config{
ClaudeAPIKey: getEnv("CLAUDE_API_KEY", "your_claude_api_key"),
ClaudeAPIURL: getEnv("CLAUDE_API_URL", "https://api.anthropic.com/v1/messages"), // 示例
QwenAPIKey: getEnv("QWEN_API_KEY", "your_qwen_api_key"),
QwenAPIURL: getEnv("QWEN_API_URL", "qwen_api_endpoint_here"), // 替换为实际的 Qwen API
SerpAPIKey: getEnv("SERPAPI_API_KEY", "your_serpapi_key"),
}
}
func getEnv(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
log.Printf("环境变量 %s 未设置,将使用回退/默认值。", key)
return fallback
}
在项目根目录下创建一个 .env
文件:
env
CLAUDE_API_KEY=sk-ant-your-claude-key...
QWEN_API_KEY=your_qwen_api_key_or_token...
SERPAPI_API_KEY=your_serpapi_search_key... # 如果使用 SerpAPI 进行搜索
2. pkg/llmclient/llmclient.go
这将是一个通用的客户端。您需要根据 Claude 和 Qwen 的特定请求/响应格式对其进行调整。
go
package llmclient
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
)
// GenericLLMRequest 和 GenericLLMResponse 仅为示例。
// 您必须根据 Claude 和 Qwen 的实际 API 规范调整这些结构。
// 类似 Claude messages API 的示例
type ClaudeMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type ClaudeRequest struct {
Model string `json:"model"`
Messages []ClaudeMessage `json:"messages"`
MaxTokens int `json:"max_tokens,omitempty"`
System string `json:"system,omitempty"` // 系统提示
}
type ClaudeContentBlock struct {
Type string `json:"type"`
Text string `json:"text"`
}
type ClaudeResponse struct {
ID string `json:"id"`
Type string `json:"type"`
Role string `json:"role"`
Content []ClaudeContentBlock `json:"content"`
// 添加其他字段,如 usage, stop_reason 等
}
// 如果 Qwen 的 API 类似,您将需要类似的结构体,或者一个更通用的结构体
// 针对 Qwen (这只是一个猜测,请检查其官方 API 文档)
type QwenInput struct {
Prompt string `json:"prompt"`
// 其他参数
}
type QwenRequest struct {
Model string `json:"model"`
Input QwenInput `json:"input"`
// 参数等
}
type QwenChoice struct {
Text string `json:"text"` // 或者 "message": {"content": "..."}
// ...
}
type QwenResponse struct {
Output struct {
Text string `json:"text"` // 这高度依赖于具体模型
} `json:"output"`
// 或者 Choices []QwenChoice `json:"choices"`
// ...
}
type LLMClient struct {
httpClient *http.Client
}
func NewLLMClient() *LLMClient {
return &LLMClient{
httpClient: &http.Client{Timeout: 90 * time.Second},
}
}
// CallClaudeAPI - 专用于 Claude Messages API
func (c *LLMClient) CallClaudeAPI(apiKey, apiURL, modelName, systemPrompt string, messages []ClaudeMessage, maxTokens int) (string, error) {
claudeReq := ClaudeRequest{
Model: modelName,
Messages: messages,
MaxTokens: maxTokens,
System: systemPrompt,
}
reqBody, err := json.Marshal(claudeReq)
if err != nil {
return "", fmt.Errorf("序列化 Claude 请求失败: %w", err)
}
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(reqBody))
if err != nil {
return "", fmt.Errorf("创建 Claude 请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", apiKey)
req.Header.Set("anthropic-version", "2023-06-01") // Claude API 要求
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("调用 Claude API 失败: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("读取 Claude 响应体失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Claude API 请求失败,状态码 %d: %s", resp.StatusCode, string(respBody))
}
var claudeResp ClaudeResponse
if err := json.Unmarshal(respBody, &claudeResp); err != nil {
return "", fmt.Errorf("反序列化 Claude 响应失败: %w. 响应体: %s", err, string(respBody))
}
if len(claudeResp.Content) > 0 && claudeResp.Content[0].Type == "text" {
return claudeResp.Content[0].Text, nil
}
return "", errors.New("在 Claude 响应中未找到文本内容")
}
// CallQwenAPI - 根据 Qwen 的特定 API 结构进行调整
func (c *LLMClient) CallQwenAPI(apiKey, apiURL, modelName, prompt string) (string, error) {
// 这是一个非常通用的占位符 - 需要 Qwen API 的详细信息
// 使用假设的 Qwen API 结构的示例
qwenReqPayload := QwenRequest{
Model: modelName, // 例如 "qwen-turbo"
Input: QwenInput{Prompt: prompt},
// Parameters: {"temperature": 0.7}, // 示例
}
reqBody, err := json.Marshal(qwenReqPayload)
if err != nil {
return "", fmt.Errorf("序列化 Qwen 请求失败: %w", err)
}
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(reqBody))
if err != nil {
return "", fmt.Errorf("创建 Qwen 请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey) // 许多 API 的通用做法
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("调用 Qwen API 失败: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("读取 Qwen 响应体失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Qwen API 请求失败,状态码 %d: %s", resp.StatusCode, string(respBody))
}
var qwenResp QwenResponse
if err := json.Unmarshal(respBody, &qwenResp); err != nil {
// 如果反序列化失败,尝试打印原始响应体以进行调试
return "", fmt.Errorf("反序列化 Qwen 响应失败: %w. 原始响应体: %s", err, string(respBody))
}
// 根据 Qwen 的 API 结构提取实际的文本响应
// 这高度依赖于 Qwen API
if qwenResp.Output.Text != "" {
return qwenResp.Output.Text, nil
}
// if len(qwenResp.Choices) > 0 {
// return qwenResp.Choices[0].Text, nil // 或者 qwenResp.Choices[0].Message.Content
// }
return "", errors.New("在 Qwen 响应中未找到文本内容或格式无法识别")
}
3. pkg/tools/tools.go
和 pkg/tools/websearch.go
go
// pkg/tools/tools.go
package tools
import "fmt"
const (
ToolWebSearch = "web_search"
)
// ToolInput 代表任何工具的输入
type ToolInput map[string]interface{}
// ToolOutput 代表任何工具的输出
type ToolOutput struct {
Result string
Err error
}
// Tool 代表一个可执行的工具
type Tool interface {
Name() string
Description() string // 供 LLM 理解该工具的作用
Execute(input ToolInput) ToolOutput
}
// ToolRegistry 持有可用的工具
type ToolRegistry struct {
tools map[string]Tool
}
func NewToolRegistry() *ToolRegistry {
return &ToolRegistry{tools: make(map[string]Tool)}
}
func (r *ToolRegistry) Register(tool Tool) {
r.tools[tool.Name()] = tool
}
func (r *ToolRegistry) GetTool(name string) (Tool, bool) {
tool, ok := r.tools[name]
return tool, ok
}
func (r *ToolRegistry) GetAvailableToolsDescription() string {
desc := "可用工具:\n"
for _, tool := range r.tools {
desc += fmt.Sprintf("- 名称: %s, 描述: %s\n", tool.Name(), tool.Description())
}
return desc
}
go
// pkg/tools/websearch.go
package tools
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"your_project_path/my-agent-app/pkg/config" // 调整路径
)
type WebSearchTool struct{}
func (t *WebSearchTool) Name() string {
return ToolWebSearch
}
func (t *WebSearchTool) Description() string {
return "使用 SerpApi 执行网络搜索以查找在线信息。输入应为一个 JSON 对象,其中包含 'query' 字段,例如 {\"query\": \"最新 AI 新闻\"}。"
}
// SerpAPIResult 代表搜索结果的简化结构
type SerpAPIOrganicResult struct {
Title string `json:"title"`
Link string `json:"link"`
Snippet string `json:"snippet"`
}
type SerpAPIResponse struct {
OrganicResults []SerpAPIOrganicResult `json:"organic_results"`
AnswerBox struct {
Answer string `json:"answer"`
Snippet string `json:"snippet"`
Title string `json:"title"`
} `json:"answer_box"`
Error string `json:"error"`
}
func (t *WebSearchTool) Execute(input ToolInput) ToolOutput {
query, ok := input["query"].(string)
if !ok || query == "" {
return ToolOutput{Err: fmt.Errorf("web_search 工具需要在输入中提供 'query' 字符串")}
}
log.Printf("WebSearchTool:正在执行查询:%s\n", query)
// 以 SerpAPI 为例。您可以将其替换为任何搜索提供商。
// 确保 SERPAPI_API_KEY 在您的 .env 文件或环境变量中
apiKey := config.AppConfig.SerpAPIKey
if apiKey == "" || apiKey == "your_serpapi_key" {
log.Println("SERPAPI_API_KEY 未配置。网络搜索将被跳过/模拟。")
return ToolOutput{Result: fmt.Sprintf("模拟搜索结果:%s。(SERPAPI_API_KEY 未设置)", query)}
}
searchURL := fmt.Sprintf("https://serpapi.com/search.json?q=%s&api_key=%s", url.QueryEscape(query), apiKey)
resp, err := http.Get(searchURL)
if err != nil {
return ToolOutput{Err: fmt.Errorf("执行网络搜索 GET 请求失败:%w", err)}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return ToolOutput{Err: fmt.Errorf("网络搜索 API 返回状态 %d:%s", resp.StatusCode, string(bodyBytes))}
}
var serpResp SerpAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&serpResp); err != nil {
return ToolOutput{Err: fmt.Errorf("解码网络搜索 API 响应失败:%w", err)}
}
if serpResp.Error != "" {
return ToolOutput{Err: fmt.Errorf("网络搜索 API 错误:%s", serpResp.Error)}
}
var resultsText strings.Builder
if serpResp.AnswerBox.Answer != "" {
resultsText.WriteString(fmt.Sprintf("答案框:%s\n%s\n\n", serpResp.AnswerBox.Title, serpResp.AnswerBox.Snippet))
}
for i, item := range serpResp.OrganicResults {
if i >= 3 { // 限制为前3个结果以保持简洁
break
}
resultsText.WriteString(fmt.Sprintf("标题:%s\n链接:%s\n摘要:%s\n\n", item.Title, item.Link, item.Snippet))
}
if resultsText.Len() == 0 {
return ToolOutput{Result: "未找到相关搜索结果。"}
}
return ToolOutput{Result: resultsText.String()}
}
4. pkg/planner/planner.go
go
package planner
import (
"encoding/json"
"fmt"
"log"
"your_project_path/my-agent-app/pkg/config" // 调整路径
"your_project_path/my-agent-app/pkg/llmclient" // 调整路径
"your_project_path/my-agent-app/pkg/tools" // 调整路径
)
// Task 代表计划中的单个任务
type Task struct {
ID int `json:"id"`
Description string `json:"description"`
ToolName string `json:"tool_name,omitempty"` // 例如 "web_search"
ToolInput map[string]interface{} `json:"tool_input,omitempty"` // 例如 {"query": "某个搜索词"}
Status string `json:"status"` // "todo" (待办), "in_progress" (进行中), "done" (已完成), "error" (错误)
Result string `json:"result,omitempty"` // 工具或 LLM 的输出
DependsOn []int `json:"depends_on,omitempty"` // 必须首先完成的任务的 ID
}
// Plan 是任务列表
type Plan struct {
Tasks []Task `json:"tasks"`
}
type Planner struct {
llm *llmclient.LLMClient
claudeConfig config.Config // 用于访问 API 密钥和 URL
toolRegistry *tools.ToolRegistry
}
func NewPlanner(llm *llmclient.LLMClient, cfg config.Config, tr *tools.ToolRegistry) *Planner {
return &Planner{llm: llm, claudeConfig: cfg, toolRegistry: tr}
}
func (p *Planner) CreatePlan(userPrompt string) (*Plan, error) {
systemPrompt := `
你是一个专家级的规划代理。你的目标是将用户的请求分解为一系列可操作的子任务。
对于每个任务,你必须:
1. 提供一个清晰的 "description" (描述) 说明需要做什么。
2. 如果需要工具,指定 "tool_name" (工具名称)。
3. 如果指定了工具,以 JSON 对象的形式提供必要的 "tool_input" (工具输入)。
4. 如果一个任务依赖于先前任务的输出,用 "depends_on" (任务 ID 列表) 来指明。第一个任务的 ID 为 1。
可用工具:
` + p.toolRegistry.GetAvailableToolsDescription() + `
如果任务是关于推理、总结或基于先前信息回答问题而不需要特定工具,则不要指定 "tool_name"。执行器 LLM 将处理它。
将计划输出为一个 JSON 对象,该对象有一个名为 "tasks" 的键,其值为一个任务对象数组。每个任务对象应包含 "id"、"description"、"tool_name" (可选)、"tool_input" (如果无 tool_name 则可选) 和 "depends_on" (可选)。
任务 ID 从 1 开始。
网络搜索任务示例:
{
"tasks": [
{
"id": 1,
"description": "搜索伦敦当前的天气。",
"tool_name": "web_search",
"tool_input": {"query": "伦敦当前天气"}
},
{
"id": 2,
"description": "总结搜索结果。",
"depends_on": [1]
// 没有 tool_name,意味着执行器 LLM 将根据任务1的结果进行总结
}
]
}
`
messages := []llmclient.ClaudeMessage{
{Role: "user", Content: userPrompt},
}
// 使用 Claude Opus 进行规划,根据需要调整模型
// 确保 CLAUDE_API_URL 和 CLAUDE_API_KEY 已正确设置
// Max tokens 可能需要调整
rawPlan, err := p.llm.CallClaudeAPI(
p.claudeConfig.ClaudeAPIKey,
p.claudeConfig.ClaudeAPIURL,
"claude-3-opus-20240229", // 或者 "claude-3-5-sonnet-20240620"
systemPrompt,
messages,
2048, // 计划的 Max tokens
)
if err != nil {
return nil, fmt.Errorf("规划器 LLM 调用失败: %w", err)
}
log.Printf("从 LLM 获取的原始计划:\n%s\n", rawPlan)
var plan Plan
// LLM 可能直接返回 JSON,或者返回包含 JSON 的文本。
// 如果 JSON 被文本或 markdown 包裹,尝试提取它。
startIndex := -1
endIndex := -1
for i, r := range rawPlan {
if r == '{' && startIndex == -1 { // 查找第一个 {
// 检查它是否是 "tasks" 对应整个 JSON 对象的开始
if i+8 < len(rawPlan) && rawPlan[i:i+8] == "{\"tasks\"" {
startIndex = i
}
}
if r == '}' { // 查找最后一个 }
// 检查它是否是主对象内 "tasks" 数组的结束括号
// 这有点棘手;一个更健壮的解析器或更受约束的 LLM 输出会更好。
// 目前,我们假设如果 LLM 正确开始,它会输出一个干净的 JSON 对象。
if startIndex != -1 {
endIndex = i
}
}
}
var jsonStr string
if startIndex != -1 && endIndex != -1 && startIndex < endIndex {
jsonStr = rawPlan[startIndex : endIndex+1]
} else {
// 如果未找到清晰的 JSON 对象标记,则回退,假设 rawPlan 就是 JSON 字符串
log.Println("未能找到清晰的 JSON 对象标记,尝试直接解析 rawPlan。")
jsonStr = rawPlan
}
if err := json.Unmarshal([]byte(jsonStr), &plan); err != nil {
return nil, fmt.Errorf("从 LLM 输出反序列化计划失败:%w。原始输出:%s", err, rawPlan)
}
// 初始化所有任务的状态
for i := range plan.Tasks {
plan.Tasks[i].Status = "todo"
}
return &plan, nil
}
5. pkg/scheduler/scheduler.go
go
package scheduler
import (
"encoding/json"
"fmt"
"log"
"strings"
"your_project_path/my-agent-app/pkg/config" // 调整路径
"your_project_path/my-agent-app/pkg/llmclient" // 调整路径
"your_project_path/my-agent-app/pkg/planner" // 调整路径
"your_project_path/my-agent-app/pkg/tools" // 调整路径
)
type Scheduler struct {
llm *llmclient.LLMClient
qwenConfig config.Config // 用于访问 Qwen API 密钥和 URL
toolRegistry *tools.ToolRegistry
}
func NewScheduler(llm *llmclient.LLMClient, cfg config.Config, tr *tools.ToolRegistry) *Scheduler {
return &Scheduler{llm: llm, qwenConfig: cfg, toolRegistry: tr}
}
func (s *Scheduler) ExecutePlan(userPrompt string, plan *planner.Plan) (string, error) {
maxIterations := 10 // 循环的安全中断
iteration := 0
// 提供给 Qwen (或执行器 LLM) 的上下文
var executionContext []string
executionContext = append(executionContext, fmt.Sprintf("原始用户请求:%s", userPrompt))
for iteration < maxIterations {
iteration++
log.Printf("\n--- 迭代 %d ---\n", iteration)
nextTask, taskIndex := s.findNextTask(plan)
if nextTask == nil {
log.Println("没有更多任务可执行或所有依赖项均已满足。")
break // 所有任务已完成或卡住
}
log.Printf("正在执行任务 ID %d:%s\n", nextTask.ID, nextTask.Description)
plan.Tasks[taskIndex].Status = "in_progress"
var taskResult string
var taskErr error
if nextTask.ToolName != "" {
tool, ok := s.toolRegistry.GetTool(nextTask.ToolName)
if !ok {
taskErr = fmt.Errorf("任务 ID %d 的工具 '%s' 未找到", nextTask.ToolName, nextTask.ID)
taskResult = fmt.Sprintf("错误:%s", taskErr.Error())
} else {
// 准备工具输入,可能使用依赖任务的结果
toolInput := nextTask.ToolInput
if toolInput == nil {
toolInput = make(map[string]interface{})
}
// 基本的依赖注入 (非常简化)
// 一个更健壮的系统会将先前任务的特定输出映射到当前任务的输入
for _, depID := range nextTask.DependsOn {
depTask, found := s.findTaskByID(plan, depID)
if found && depTask.Status == "done" && depTask.Result != "" {
// 简单:将所有依赖结果添加到工具 (或LLM) 的上下文中
// 更复杂:输出到输入的特定映射
toolInput[fmt.Sprintf("dependency_result_task_%d", depID)] = depTask.Result
}
}
log.Printf("调用工具 '%s',输入为:%+v\n", nextTask.ToolName, toolInput)
output := tool.Execute(toolInput)
taskResult = output.Result
taskErr = output.Err
}
} else {
// 由执行器 LLM (Qwen) 处理的任务
// 根据任务描述和上下文构建 Qwen 的提示
qwenPrompt := s.buildQwenPromptForTask(userPrompt, plan, nextTask, executionContext)
log.Printf("请求 Qwen 执行任务:\n%s\n", nextTask.Description)
// 使用 Qwen API (确保 QWEN_API_URL 和 QWEN_API_KEY 正确)
// 根据需要调整模型名称
qwenResult, err := s.llm.CallQwenAPI(
s.qwenConfig.QwenAPIKey,
s.qwenConfig.QwenAPIURL,
"qwen-turbo", // 或 "qwen-plus", "qwen-max" 等
qwenPrompt,
)
if err != nil {
taskErr = fmt.Errorf("任务的 Qwen LLM 调用失败:%w", err)
taskResult = fmt.Sprintf("错误:%s", taskErr.Error())
} else {
taskResult = qwenResult
}
}
if taskErr != nil {
log.Printf("执行任务 ID %d 时出错:%v\n", nextTask.ID, taskErr)
plan.Tasks[taskIndex].Status = "error"
plan.Tasks[taskIndex].Result = taskResult // 将错误消息存储为结果
// 基本错误处理:停止或允许 LLM 重新规划 (更高级)
// 目前,我们将让它继续,看看其他分支是否可以完成。
// 或者,你可以在这里立即返回错误。
} else {
log.Printf("任务 ID %d 结果:%s\n", nextTask.ID, taskResult)
plan.Tasks[taskIndex].Status = "done"
plan.Tasks[taskIndex].Result = taskResult
}
executionContext = append(executionContext, fmt.Sprintf("任务 %d (%s) 结果:%s", nextTask.ID, nextTask.Description, taskResult))
// 检查所有任务是否已完成
if s.allTasksDone(plan) {
log.Println("所有任务已完成。")
break
}
}
// 使用 Qwen (或其他 LLM) 进行最终综合步骤
finalAnswer, err := s.synthesizeFinalAnswer(userPrompt, plan, executionContext)
if err != nil {
return "", fmt.Errorf("综合最终答案失败:%w", err)
}
return finalAnswer, nil
}
func (s *Scheduler) findNextTask(plan *planner.Plan) (*planner.Task, int) {
for i, task := range plan.Tasks {
if task.Status == "todo" {
dependenciesMet := true
for _, depID := range task.DependsOn {
depTask, found := s.findTaskByID(plan, depID)
if !found || (depTask.Status != "done" && depTask.Status != "skipped_due_to_error") { // 如果依赖项出错,则允许跳过
dependenciesMet = false
break
}
}
if dependenciesMet {
return &plan.Tasks[i], i // 返回指向切片中任务的指针
}
}
}
return nil, -1 // 未找到合适的任务
}
func (s *Scheduler) findTaskByID(plan *planner.Plan, id int) (*planner.Task, bool) {
for i := range plan.Tasks {
if plan.Tasks[i].ID == id {
return &plan.Tasks[i], true
}
}
return nil, false
}
func (s *Scheduler) allTasksDone(plan *planner.Plan) bool {
for _, task := range plan.Tasks {
if task.Status != "done" && task.Status != "error" && task.Status != "skipped_due_to_error" { // 在此检查中,将错误视为"已完成"的一种形式
return false
}
}
return true
}
func (s *Scheduler) buildQwenPromptForTask(originalUserPrompt string, plan *planner.Plan, currentTask *planner.Task, history []string) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("你是一个正在执行计划的 AI 助手。原始用户请求是:\"%s\"\n\n", originalUserPrompt))
sb.WriteString("当前计划状态:\n")
for _, t := range plan.Tasks {
status := t.Status
if t.ID == currentTask.ID {
status = "进行中 (当前任务)"
}
sb.WriteString(fmt.Sprintf("- 任务 ID %d:%s (状态:%s)\n", t.ID, t.Description, status))
if t.Status == "done" && t.Result != "" {
trimmedResult := t.Result
if len(trimmedResult) > 200 { // 保持上下文简洁
trimmedResult = trimmedResult[:200] + "..."
}
sb.WriteString(fmt.Sprintf(" 结果:%s\n", trimmedResult))
}
}
sb.WriteString("\n执行历史 (选定的先前结果):\n")
for _, h := range history {
trimmedHistory := h
if len(trimmedHistory) > 300 {
trimmedHistory = trimmedHistory[:300] + "..."
}
sb.WriteString(fmt.Sprintf("- %s\n", trimmedHistory))
}
sb.WriteString(fmt.Sprintf("\n你当前的任务 (ID %d) 是:\"%s\"。\n", currentTask.ID, currentTask.Description))
// 如果有,添加直接依赖项的结果
if len(currentTask.DependsOn) > 0 {
sb.WriteString("此任务依赖于以下已完成任务的结果:\n")
for _, depID := range currentTask.DependsOn {
depTask, found := s.findTaskByID(plan, depID)
if found && depTask.Status == "done" && depTask.Result != "" {
sb.WriteString(fmt.Sprintf(" - 任务 %d 结果:%s\n", depID, depTask.Result))
}
}
}
sb.WriteString("\n请执行此任务,并仅提供任务描述所要求的直接结果或信息。不要添加对话式的冗余内容。如果任务是总结或分析,请提供该总结/分析。")
return sb.String()
}
func (s *Scheduler) synthesizeFinalAnswer(userPrompt string, plan *planner.Plan, executionContext []string) (string, error) {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("原始用户请求是:\"%s\"\n\n", userPrompt))
sb.WriteString("执行了以下计划:\n")
for _, task := range plan.Tasks {
sb.WriteString(fmt.Sprintf("- 任务 ID %d:%s (状态:%s)\n", task.ID, task.Description, task.Status))
if task.Result != "" {
// 为最终提示总结长结果
resultSummary := task.Result
if len(resultSummary) > 500 { // 每个任务在最终提示中的结果最多 500 个字符
resultSummary = resultSummary[:500] + "..."
}
sb.WriteString(fmt.Sprintf(" 结果:%s\n", resultSummary))
}
}
sb.WriteString("\n根据执行历史和任务结果,为用户的原始请求提供一个全面的最终答案。")
sb.WriteString("清晰简洁地呈现答案。如果由于错误导致请求的某些部分无法完成,请提及这一点。")
finalPrompt := sb.String()
log.Printf("\n--- 综合最终答案 ---\n给 Qwen 的提示:\n%s\n", finalPrompt)
answer, err := s.llm.CallQwenAPI(
s.qwenConfig.QwenAPIKey,
s.qwenConfig.QwenAPIURL,
"qwen-plus", // 如果可用,使用更强大的模型进行综合
finalPrompt,
)
if err != nil {
return "", fmt.Errorf("Qwen LLM 最终综合调用失败:%w", err)
}
return answer, nil
}
// 辅助函数,将计划编组为字符串以用于日志记录或上下文
func (s *Scheduler) planToString(plan *planner.Plan) string {
planBytes, err := json.MarshalIndent(plan, "", " ")
if err != nil {
return "编组计划时出错"
}
return string(planBytes)
}
6. cmd/main.go
go
package main
import (
"bufio"
"fmt"
"log"
"os"
"strings"
"your_project_path/my-agent-app/pkg/config" // 调整路径
"your_project_path/my-agent-app/pkg/llmclient" // 调整路径
"your_project_path/my-agent-app/pkg/planner" // 调整路径
"your_project_path/my-agent-app/pkg/scheduler" // 调整路径
"your_project_path/my-agent-app/pkg/tools" // 调整路径
)
func main() {
config.LoadConfig() // 加载 API 密钥和 URL
// 初始化 LLM 客户端
llm := llmclient.NewLLMClient()
// 初始化工具注册表并注册工具
toolRegistry := tools.NewToolRegistry()
toolRegistry.Register(&tools.WebSearchTool{})
// 如果创建了更多工具,请在此处添加
// 初始化规划器
taskPlanner := planner.NewPlanner(llm, config.AppConfig, toolRegistry)
// 初始化调度器
taskScheduler := scheduler.NewScheduler(llm, config.AppConfig, toolRegistry)
log.Println("Go Agent 系统已初始化。请输入您的提示 (或输入 'exit' 退出):")
scanner := bufio.NewScanner(os.Stdin)
for {
fmt.Print("> ")
if !scanner.Scan() {
break
}
userInput := scanner.Text()
userInput = strings.TrimSpace(userInput)
if strings.ToLower(userInput) == "exit" {
log.Println("正在退出。")
break
}
if userInput == "" {
continue
}
log.Println("用户提示:", userInput)
// 1. 创建计划
log.Println("--- 规划阶段 ---")
currentPlan, err := taskPlanner.CreatePlan(userInput)
if err != nil {
log.Printf("创建计划时出错:%v\n", err)
continue
}
if currentPlan == nil || len(currentPlan.Tasks) == 0 {
log.Println("规划器未返回任何任务。")
fmt.Println("抱歉,我无法为该请求制定计划。")
continue
}
log.Println("生成的计划:")
for _, task := range currentPlan.Tasks {
log.Printf(" ID: %d, 描述: %s, 工具: %s, 输入: %v, 依赖: %v\n", task.ID, task.Description, task.ToolName, task.ToolInput, task.DependsOn)
}
// 2. 执行计划
log.Println("--- 执行阶段 ---")
finalAnswer, err := taskScheduler.ExecutePlan(userInput, currentPlan)
if err != nil {
log.Printf("执行计划时出错:%v\n", err)
fmt.Println("抱歉,处理您的请求时发生错误。")
continue
}
fmt.Println("\n=== 最终答案 ===")
fmt.Println(finalAnswer)
fmt.Println("====================")
}
if err := scanner.Err(); err != nil {
log.Fatalf("读取输入时出错:%v", err)
}
}
运行此项目:
- 替换占位符:
your_project_path/my-agent-app
替换为你的实际 Go 模块路径。- 在
.env
文件中或作为环境变量设置your_claude_api_key
、qwen_api_endpoint_here
(Qwen API 的实际端点)、your_qwen_api_key
。 - 如果你使用 SerpAPI,在
.env
中设置SERPAPI_API_KEY
。 - 至关重要:使用 Claude 和 Qwen 正确的 API 请求/响应结构更新
llmclient.go
。提供的结构仅为示例性。 你需要查阅它们的 API 文档。Qwen 部分尤其具有推测性,你需要找到其确切的 API 格式。
- 安装依赖:
go get github.com/joho/godotenv
- 构建和运行:
go mod init your_project_path/my-agent-app
(如果尚未执行)go mod tidy
go run cmd/main.go
重要考虑因素和改进方向:
- LLM API 具体细节:
llmclient.go
是通用的。你 必须 根据你打算使用的 Claude 和 Qwen API 的确切 OpenAPI 规范来调整请求和响应结构体 (ClaudeRequest
,ClaudeResponse
,QwenRequest
,QwenResponse
) 以及请求构建逻辑。这是使其工作的最关键部分。 - 提示工程 (Prompt Engineering): 计划和执行的质量在很大程度上取决于提供给 Claude (规划器) 和 Qwen (调度器/执行器) 的系统提示。你需要对这些提示进行迭代优化。
- 错误处理: 当前的错误处理是基础的。你可能需要更复杂的重试机制,或者在任务失败时让 LLM 重新规划的方法。
- 状态管理: 对于长时间运行的任务或更复杂的交互,你可能需要持久化计划的状态 (例如,存储在数据库中)。
- 工具输入/输出: 当前的工具输入 (
map[string]interface{}
) 很灵活但不是类型安全的。对于更复杂的工具,应定义特定的输入/输出结构体。LLM 需要能够为tool_input
生成正确的 JSON。 - 依赖管理:
DependsOn
逻辑很简单。对于复杂的工作流程,可能需要更健壮的依赖图和执行策略。 - 上下文窗口: 在构建提示时,尤其是在
executionContext
和synthesizeFinalAnswer
提示中,要注意 LLM 的上下文窗口限制。 - 成本: LLM API 调用可能很昂贵。实施日志记录和监控。
- 安全: 如果工具可以执行任意代码或访问敏感系统,请务必小心。对输入进行清理。
- 流式处理 (Streaming): 为了更好的用户体验,可以考虑从 LLM 流式传输响应。
这个全面的指南应该能为您提供一个坚实的基础。关键是从一个部分开始,让它工作起来 (例如,规划器的 LLM 调用),然后逐步构建其他组件。请务必仔细查阅 Claude 和 Qwen 的 API 文档。