当前位置: 首页
AI教程
Agent心跳:主循环与System Prompt(手写系列第3篇)

Agent心跳:主循环与System Prompt(手写系列第3篇)

热心网友 时间:2026-07-21
转载

Agent主循环是协调LLM调用与工具执行的核心,通过Run()外循环与loopStep()内循环实现。用户输入追加至对话历史后,循环调用LLM直至返回纯文本答案或超出最大轮次。工具调用经权限检查与钩子拦截后批量执行,结果回传继续循环。SystemPrompt作为系统消息嵌入历史,维持对话上下文。

从零开始构建 Agent 系列 — 第 3 篇:Agent 的"脉搏" — 工作主循环与系统提示词

回顾一下进度:前两篇文章我们已搭建好 Agent 的骨架与大脑。第一篇完成了 CLI 骨架(Cobra 三子命令 + .env 加载),第二篇实现了 Provider 层(OpenAI / Anthropic 双后端 + 流式传输)。

从零到一手撸 Agent 系列 — 第 3 篇:Agent 的

关键问题来了——这两个模块目前各自为政,需要一个"指挥官"将它们串联起来。这个指挥官正是 Agent 主循环,也是整个项目最核心的代码:它决定何时调用 LLM、何时执行工具、何时停下来宣告"任务完成"。

完成本篇后,你的 Agent 将真正拥有"心跳"。

一、Agent 结构体:这个"指挥官"掌握哪些资源?

type Agent struct {
    cfg        Config                 // 配置(模型、token 上限等)
    prov       provider.Provider      // LLM 后端
    registry   *tools.Registry        // 工具注册表
    checker    permission.Checker     // 权限检查器
    hooks      ToolHooks              // 事件钩子
    messages   []provider.Message     // 对话历史(核心状态)
    sink       event.Sink             // 事件输出(终端/UI)
    // ... 后面文章会讲的:压缩状态、记忆、session 等
}

重中之重是 messages:它构成了 Agent 的唯一状态。整个对话的上下文都存放在这个切片里——系统提示词、用户输入、LLM 回复、工具调用、工具结果,全部是 []provider.Message。Agent 的每一次"思考"过程,就是在这个切片末尾追加新消息,然后整体发送给 LLM。

构建 Agent 时采用 Option 模式——Go 语言中常见的依赖注入方式:

a, err := agent.NewAgent(cfg,
    agent.WithRegistry(registry),   // 注入工具
    agent.WithChecker(checker),     // 注入权限
    agent.WithHooks(hookRunner),    // 注入钩子
    agent.WithSink(sink),           // 注入事件输出
)

每个 WithXxx 返回一个 Option 接口的实现,内部只是将值赋值给 Agent 结构体。好处是:新增依赖时无需修改 NewAgent 的函数签名,调用方也只传递自己关心的选项。

二、Run():外循环 — 一切从这里启动

func (a *Agent) Run(ctx context.Context, userInput string) (string, error) {
    // 1. 用户输入追加为 user 消息
    a.messages = append(a.messages, provider.Message{
        Role:    provider.RoleUser,
        Content: userInput,
    })

    // 2. 最多跑 MaxTurns 轮(默认 100)
    for turn := 0; turn < a.cfg.MaxTurns; turn++ {
        final, err := a.loopStep(ctx)
        if err != nil {
            return "", err
        }
        // 3. 如果 loopStep 返回了非空 final,说明 LLM 给出了最终答案
        if final != "" {
            a.Sa veCurrentSession() // 自动保存
            return final, nil
        }
    }
    // 4. 超过 MaxTurns:强制结束
    return "", fmt.Errorf("%w (limit=%d)", ErrMaxTurnsExceeded, a.cfg.MaxTurns)
}

这一逻辑非常直观:用户输入被追加为 user 消息,然后进入一个 for 循环。每次循环调用 loopStep()——如果返回非空字符串,那就是最终答案,直接结束;否则继续下一轮。最多执行 MaxTurns 次。

MaxTurns 作为一个安全阀——防止 LLM 陷入"调工具→没结果→再调→又没结果"的死循环。默认 100 轮,绝大多数场景足矣;如果任务是"帮我重构整个项目",LLM 可能需要更多轮次,可以通过 --max-turns 200 调大。

三、loopStep():一轮"心跳"

这是整个项目最重要的函数,我们来逐段拆解。

3.1 发起 LLM 请求

func (a *Agent) loopStep(ctx context.Context) (final string, err error) {
    // onText: 每收到 LLM 流式输出的一个字就推送给 UI
    onText := func(s string) {
        a.sink.Emit(event.Event{Kind: event.Text, Text: s})
    }

    // 修复孤儿 tool 消息,防止 API 400
    a.messages = provider.SanitizeToolPairing(a.messages)

    // 检查是否需要压缩上下文
    a.maybeCompact(ctx, a.lastPromptTokens)

    // 构造请求
    req := a.buildRequest()

    // 调用 LLM(流式)
    ch, err := a.prov.Stream(ctx, req)
    if err != nil {
        // 上下文溢出?尝试紧急压缩后重试
        if provider.IsPromptTooLong(err) && !a.hasAttemptedReactiveCompact {
            a.reactiveCompact(ctx)
            a.hasAttemptedReactiveCompact = true
            return "", nil // 回到 Run 的 for 循环重试
        }
        return "", fmt.Errorf("调用 LLM 失败: %w", err)
    }
    // ...
}

3.2 收集流式结果

msg, usage, streamErr := provider.CollectWithText(ch, onText)
if streamErr != nil {
    // 流中断但已有部分输出?注入恢复 prompt 重试
    if provider.IsStreamInterrupted(streamErr) {
        if msg.Content != "" {
            a.messages = append(a.messages, provider.Message{
                Role:    provider.RoleAssistant,
                Content: msg.Content,
            })
        }
        a.messages = append(a.messages, provider.Message{
            Role:    provider.RoleUser,
            Content: "[流式传输中断] 请从断点继续,不要重复已输出的内容。",
        })
        return "", nil // 回到 for 循环重试
    }
    return "", fmt.Errorf("调用 LLM 失败: %w", streamErr)
}

流中断恢复的策略值得注意:不是直接重试(那样 LLM 会重新输出已经显示给用户的内容),而是把已输出的部分当作 assistant 消息存入历史,然后追加一条"请从断点继续"的 user 消息——让 LLM 无缝续写。

3.3 判断:文本回答还是工具调用?

a.messages = append(a.messages, msg)

// 情况 A:纯文本 → LLM 已经给出最终答案
if len(msg.ToolCalls) == 0 {
    // 终答守卫:检查是否有未完成的 todo
    if force := a.checkTodoGuard(ctx); force != "" {
        a.messages = append(a.messages, provider.Message{
            Role:    provider.RoleUser,
            Content: force,
        })
        return "", nil // 继续循环,让 LLM 完成任务
    }
    // Stop hook:允许外部脚本在 Agent 结束时干预
    if a.hooks != nil {
        if force, ok := a.hooks.Stop(ctx, a.messages); ok {
            a.messages = append(a.messages, provider.Message{
                Role:    provider.RoleUser,
                Content: force,
            })
            return "", nil
        }
    }
    return msg.Content, nil // ← 最终答案!
}

// 情况 B:有 tool_calls → 执行工具,然后下一轮继续
a.executeBatch(ctx, msg.ToolCalls)
return "", nil // ← 返回空,告诉 Run() 继续循环

这就是 Agent 的核心判断:LLM 回复里有没有 tool_calls

  • 没有 → LLM 认为任务已完成,返回纯文本答案
  • 有 → LLM 希望调用工具获取更多信息,执行完工具后回到循环开头,带着工具结果再次请求 LLM

3.4 完整流程图

loopStep():
┌─→ 修复孤儿 tool 消息
│   检查是否需要压缩
│   构造请求 → Stream()
│   │
│   ├─ 错误?→ IsPromptTooLong?→ 紧急压缩 → 重试
│   │          其他错误 → 报错退出
│   │
│   └─ 成功 → CollectWithText()
│       │
│       ├─ StreamInterrupted?→ 保存已输出内容 → 注入恢复提示 → 重试
│       │
│       └─ 正常 → 追加 assistant 消息到 history
│           │
│           ├─ 无 tool_calls?→ 终答守卫 → Stop hook → 返回 final!
│           │
│           └─ 有 tool_calls?→ executeBatch() → 返回空(继续循环)
│
↓
└────────────────────────────────────────────────┘

四、invokeTool:执行一个工具

func (a *Agent) invokeTool(ctx context.Context, tc provider.ToolCall) string {
    name := tc.Name

    // 1. 解析 JSON 参数
    var args map[string]any
    json.Unmarshal([]byte(tc.Arguments), &args)

    // 2. PreToolUse hook:外部脚本可在工具执行前拦截
    if a.hooks != nil {
        if blocked, msg := a.hooks.PreToolUse(ctx, name, args); blocked {
            return "Blocked by hook: " + msg
        }
    }

    // 3. 权限检查:DenyList → BashAsk → WorkdirBoundary 三级管线
    if a.checker != nil {
        r := a.checker.Check(ctx, name, args)
        if r.Decision == permission.DecisionDeny {
            return "Permission denied: " + r.Reason
        }
    }

    // 4. 查找并执行工具
    tool := a.registry.Get(name)
    if tool == nil {
        return fmt.Sprintf("Error: 工具 %q 未注册", name)
    }
    out, err := tool.Execute(ctx, args)

    // 5. PostToolUse hook
    if a.hooks != nil {
        a.hooks.PostToolUse(ctx, name, args, out)
    }

    return out // 成功返回工具输出,失败返回 "Error: ..."
}

调用链路十分清晰:LLM 提议 → PreToolUse Hook → 权限检查 → 工具执行 → PostToolUse Hook → 结果回传。每个环节都可能阻断,但通过将结果统一格式化为字符串,上层完全无需区分是成功、被阻断还是错误——全部作为 tool result 喂回 LLM。

五、executeBatch:批量执行 + 并行优化

当 LLM 一次性请求多个工具时(比如同时读 3 个文件),Agent 会智能地划分并行批次:

func (a *Agent) executeBatch(ctx context.Context, calls []provider.ToolCall) {
    results := make([]string, len(calls))

    // 将 tool_calls 按是否只读划分为串行/并行批次
    for _, batch := range partitionToolCalls(a.registry, calls) {
        if batch.parallel {
            runParallel(batch.start, batch.end, results, func(i int) {
                results[i] = a.invokeTool(ctx, calls[i])
            })
        } else {
            // 串行执行
            for i := batch.start; i < batch.end; i++ {
                results[i] = a.invokeTool(ctx, calls[i])
            }
        }
    }

    // 将所有结果作为 tool 消息追加到历史
    for i, tc := range calls {
        a.messages = append(a.messages, provider.Message{
            Role:       provider.RoleTool,
            ToolCallID: tc.ID,
            Name:       tc.Name,
            Content:    results[i],
        })
    }
}

分批次的关键:

func partitionToolCalls(r *tools.Registry, calls []provider.ToolCall) []toolCallBatch {
    var batches []toolCallBatch
    for i := 0; i < len(calls); {
        if isParallelisable(r, calls[i].Name) {
            // 连续的只读工具作为一个并行批次
            start := i
            for i < len(calls) && isParallelisable(r, calls[i].Name) { i++ }
            batches = append(batches, toolCallBatch{start, i, parallel: true})
        } else {
            // 写操作必须串行,单独一个批次
            batches = append(batches, toolCallBatch{start: i, end: i + 1})
            i++
        }
    }
    return batches
}

假设 LLM 说:"同时读 A.go、B.go、C.go,然后修改 D.go":

calls = [read(A), read(B), read(C), write(D)]
↓ partitionToolCalls
batches = [
    {read(A), read(B), read(C)} ← 并行(3 goroutine 同时读)
    {write(D)}                     ← 串行(等上面跑完再写)
]

写工具不能并行——文件可能互相冲突。maxParallelTools = 8 限制并发数,防止系统资源耗尽。

六、系统提示词:告诉 LLM 它是什么、能做什么

LLM 需要一个"人设"——这就是系统提示词。Agent 根据注册的工具列表自动生成:

func buildSystemPrompt(registry *tools.Registry, skills []skill.Skill) string {
    var b strings.Builder
    b.WriteString("你是一个编码助手,可以使用以下工具完成任务。\n\n")

    toolList := registry.List()
    for i, t := range toolList {
        fmt.Fprintf(&b, "%d. %s\n", i+1, t.Name())
        fmt.Fprintf(&b, "  描述: %s\n", t.Description())
        fmt.Fprintf(&b, "  参数 schema: %s\n", string(t.Schema()))
        b.WriteString("\n")
    }

    b.WriteString("请按用户意图选择合适的工具,按需连续调用多个工具以完成任务。\n")

    // 有特定工具时追加专项指导
    if hasTodoTools(registry) {
        b.WriteString(todoGuidance) // "用 todo_write 管理多步骤任务..."
    }
    if hasTaskTool(registry) {
        b.WriteString(taskGuidance) // "用 task 委派子任务给子 agent..."
    }

    return b.String()
}

这意味着:你每注册一个新工具,系统提示词会自动更新。LLM 立刻就知道自身多了什么能力。

还有一些专项指导会在特定工具存在时自动追加——比如注册了 todo_writecomplete_step 后,系统提示词里就会多出一段关于如何用这两个工具管理多步骤任务的详细说明。

七、事件系统(Event Sink):Agent 如何与外界对话

Agent 不直接写 fmt.Print——所有输出都通过 event.Sink 接口发出:

type Sink interface {
    Emit(Event)
}

type Kind int
const (
    Text            Kind = iota // LLM 输出的文本增量
    ToolDispatch             // 开始执行工具
    ToolResult               // 工具执行结果
    ApprovalRequest          // 请求用户审批
    TurnDone                 // 一轮对话结束
    Notice                   // 系统通知
)

不同的"外壳"实现不同的 Sink:

  • REPL 模式:TextSink{Out: os.Stdout} — 直接写到终端
  • TUI 模式:TuiSink — 通过 channel 推送到 Bubble Tea UI

这种"依赖倒置"让 Agent 核心完全不感知 UI 形态——你可以给它套上 REPL,也可以套上 TUI,甚至可以套上 WebSocket 做远程 Agent。

八、终答守卫(Todo Guard):防止 Agent "假装做完"

一个常见问题:Agent 说"完成了!",但其实 todo_write 里还有好几项 in_progress

终答守卫在 Agent 准备输出最终答案之前检查:

func (a *Agent) checkTodoGuard(ctx context.Context) string {
    todos := ledger.CurrentTodos()
    var incomplete []string
    for _, t := range todos {
        if t.Status != "completed" {
            incomplete = append(incomplete, t.Content+" ["+t.Status+"]")
        }
    }
    if len(incomplete) == 0 { return "" } // 全完成,放行

    // 有未完成任务 → 拒绝这次"最终回答",要求 LLM 继续工作
    return fmt.Sprintf("宿主就绪检查失败: %d/%d 待办仍未完成。请先完成剩余任务...",
        len(incomplete), len(todos))
}

如果 LLM 反复"假装做完",守卫最多阻断 maxGuardBlocks=3 次,然后放行——避免死循环。

九、完整串联:现在让 Agent 跑起来

把第一篇的 once 命令真正接上 Agent:

func runOnce(cmd *cobra.Command, args []string) error {
    workdir, _ := os.Getwd()
    cfg := buildConfig(cmd)

    // 1. 构造工具注册表
    registry := tools.DefaultRegistry(workdir)
    // DefaultRegistry 里预装了 read_file、write_file、edit_file、
    // glob_file、grep、bash、web_fetch 等基础工具

    // 2. 构造 Agent
    a, err := agent.NewAgent(cfg,
        agent.WithRegistry(registry),
        agent.WithSink(&event.TextSink{Out: os.Stdout, Err: os.Stderr}),
    )
    if err != nil { return err }

    // 3. 运行!
    final, err := a.Run(cmd.Context(), onceMessage)
    if err != nil { return err }

    fmt.Println("\n--- 最终回答 ---")
    fmt.Println(final)
    return nil
}

编译运行:

go build -o coding-agent ./cmd
./coding-agent once -m "列出当前目录所有 .go 文件"

你会看到:

[coding-agent] running once...
→ Agent 调用 glob_file(pattern="*.go")
→ glob_file 返回文件列表
→ Agent 输出:"当前目录有 3 个 Go 文件:main.go、root.go、once.go"

你的 Agent 第一次"主动干活"了!它不是直接回答,而是意识到需要先调用工具,拿到结果后再组织回答。这才是 Coding Agent 的本质。

小结

这篇我们实现了整个项目的心跳机制——Agent 主循环。

你学到了什么 对应代码
Agent 结构体 + Option 依赖注入模式 agent.gooption.go
Run() 外循环:user 消息 → for 循环 → 最终答案 agent.goRun()
loopStep() 一轮心跳:请求 LLM → 收集流 → 判断 text/tool_call loop.goloopStep()
invokeTool():工具执行管线(hook → 权限 → 执行 → hook) loop.goinvokeTool()
executeBatch():按 ReadOnly 智能串并行 loop.goexecuteBatch() + partitionToolCalls()
buildSystemPrompt():根据注册工具自动生成 system prompt prompt.go
Event Sink:Agent 与 UI 的解耦桥梁 event/event.go
终答守卫:防止 Agent "假装做完" todo_guard.go
流中断恢复 + 上下文溢出恢复 loop.go 的错误处理分支

下篇预告:我们将深入工具系统——Tool 接口的契约设计、Registry 注册表模式、以及前三个工具(read_filewrite_fileglob_file)的完整实现。这是让 Agent 真正"有手可用"的一篇。

来源:https://juejin.cn/post/7664115885457473582

游乐网为非赢利性网站,所展示的游戏/软件/文章内容均来自于互联网或第三方用户上传分享,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系youleyoucom@outlook.com。

同类文章
更多
TalkVisions实时视频翻译应用,消除语言障碍

TalkVisions实时视频翻译应用,消除语言障碍

TalkVisions是一款实时视频翻译应用,能将视频中的口语实时转录为文本并翻译成用户所选语言,以字幕形式叠加在画面上,支持多语言、低延迟,还可保存录制视频,有效消除跨语言沟通障碍。

时间:2026-07-25 22:26
AI驱动的日历管理工具Ipso

AI驱动的日历管理工具Ipso

IpsoAI是一款专为专业人士及助手打造的AI日历管理工具,能够自动协调多方日程、智能草拟邮件,并通过快速安排会议、提供智能建议及自动化工作流程,显著减少琐碎操作,帮助用户高效管理时间、提升工作效率。

时间:2026-07-25 22:25
Spectate企业级专业高效监控与事故管理一体化平台

Spectate企业级专业高效监控与事故管理一体化平台

Spectate是一款高效监控和事故管理工具,能在30秒内检测故障并推送告警。它支持Slack、PagerDuty等主流集成,提供自定义状态页面和全球性能监控。系统自动更新状态并推送修复建议,帮助团队减少沟通成本,快速解决问题。

时间:2026-07-25 22:25
阿里云通义千问2.5大模型发布 多项能力赶超GPT-4

阿里云通义千问2.5大模型发布 多项能力赶超GPT-4

通义千问2 5大模型发布,多项能力宣称赶超GPT-4,中文语境下文本理解、生成、知识问答等表现优异。相比2 1版本,理解提升9%、逻辑推理提升16%、指令遵循提升19%。开源1100亿参数模型超越Llama-3-70B,获评开源最强。已服务超9万家企业,与小米、微博等达成合作。

时间:2026-07-25 22:25
万知个人AI工作站:一站式智能阅读创作分享平台

万知个人AI工作站:一站式智能阅读创作分享平台

万知是集成多种AI能力的个人工作站,支持自然语言交互、文档快速阅读与摘要生成、PPT自动设计与优化,覆盖学术研究、商务报告、写作辅助及日常问答等场景,全方位提升工作效率。

时间:2026-07-25 22:25
热门专题
更多
刀塔传奇破解版无限钻石下载大全 刀塔传奇破解版无限钻石下载大全
洛克王国正式正版手游下载安装大全 洛克王国正式正版手游下载安装大全
思美人手游下载专区 思美人手游下载专区
好玩的阿拉德之怒游戏下载合集 好玩的阿拉德之怒游戏下载合集
不思议迷宫手游下载合集 不思议迷宫手游下载合集
百宝袋汉化组游戏最新合集 百宝袋汉化组游戏最新合集
jsk游戏合集30款游戏大全 jsk游戏合集30款游戏大全
宾果消消消原版下载大全 宾果消消消原版下载大全
  • 热门数据榜