Go + open ai 实现一个 mini manus

核心概念:

  1. 用户输入 (User Input): 整个流程的起点。
  2. 规划器 (Planner - LLM 1, 例如 Claude 3.7): 接收用户提示 (prompt),并将其分解为一系列子任务。理想情况下,每个子任务都应指定使用哪个工具以及该工具需要什么输入。
  3. 调度器/执行器 (Scheduler/Executor - LLM 2, 例如 Qwen,或者也可以是基于规则的):
    • 管理任务列表 (待办事项)。
    • 决定接下来执行哪个任务。
    • 如果任务需要工具,它会准备该工具的输入并调用它。
    • 接收来自工具的输出。
    • 根据工具的输出更新任务状态,并可能更新整体计划。
    • 一旦所有必要的任务完成,就为用户综合生成最终答案。
  4. 工具 (Tools): 执行特定操作的函数。这里我们有一个"联网搜索"工具。
  5. 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.gopkg/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)
	}
}

运行此项目:

  1. 替换占位符:
    • your_project_path/my-agent-app 替换为你的实际 Go 模块路径。
    • .env 文件中或作为环境变量设置 your_claude_api_keyqwen_api_endpoint_here (Qwen API 的实际端点)、your_qwen_api_key
    • 如果你使用 SerpAPI,在 .env 中设置 SERPAPI_API_KEY
    • 至关重要:使用 Claude 和 Qwen 正确的 API 请求/响应结构更新 llmclient.go。提供的结构仅为示例性。 你需要查阅它们的 API 文档。Qwen 部分尤其具有推测性,你需要找到其确切的 API 格式。
  2. 安装依赖:
    • go get github.com/joho/godotenv
  3. 构建和运行:
    • 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 逻辑很简单。对于复杂的工作流程,可能需要更健壮的依赖图和执行策略。
  • 上下文窗口: 在构建提示时,尤其是在 executionContextsynthesizeFinalAnswer 提示中,要注意 LLM 的上下文窗口限制。
  • 成本: LLM API 调用可能很昂贵。实施日志记录和监控。
  • 安全: 如果工具可以执行任意代码或访问敏感系统,请务必小心。对输入进行清理。
  • 流式处理 (Streaming): 为了更好的用户体验,可以考虑从 LLM 流式传输响应。

这个全面的指南应该能为您提供一个坚实的基础。关键是从一个部分开始,让它工作起来 (例如,规划器的 LLM 调用),然后逐步构建其他组件。请务必仔细查阅 Claude 和 Qwen 的 API 文档。

相关推荐
无名之辈J20 分钟前
系统崩溃(OOM)
后端
码农刚子29 分钟前
ASP.NET Core Blazor简介和快速入门 二(组件基础)
javascript·后端
间彧29 分钟前
Java ConcurrentHashMap如何合理指定初始容量
后端
catchadmin38 分钟前
PHP8.5 的新 URI 扩展
开发语言·后端·php
少妇的美梦41 分钟前
Maven Profile 教程
后端·maven
白衣鸽子1 小时前
RPO 与 RTO:分布式系统容灾的双子星
后端·架构
Jagger_1 小时前
SOLID原则与设计模式关系详解
后端
间彧1 小时前
Java: HashMap底层源码实现详解
后端
这里有鱼汤1 小时前
量化的困局:当所有人都在跑同一个因子时,我们还能赚谁的钱?
后端·python
武子康1 小时前
大数据-130 - Flink CEP 详解 - 捕获超时事件提取全解析:从原理到完整实战代码教程 恶意登录案例实现
大数据·后端·flink