☰
多Agent架构实战:围棋小程序拆出七个Agent的设计与调优
2026/9/30 5:00:56 网站建设 项目流程

1. 从一张棋盘说起:为什么一个围棋小程序要拆出七个 Agent

围棋这个场景,看起来简单——一张 19x19 的棋盘,黑白两色棋子,规则几行字就能说完。但真正动手做一个围棋小程序,你会发现它背后牵扯的东西远比想象中复杂:棋力评估、形势判断、定式推荐、复盘讲解、规则判定、新手引导、对局记录分析……每一块都需要不同的"脑子"去处理。如果把这些全部塞进一个大模型调用里,结果就是提示词越写越长、输出越来越飘、维护越来越难。

我最初做这个项目的时候,走的就是"一个大 prompt 打天下"的路子。结果呢?模型在同一个对话里既要判断死活,又要讲解定式,还要生成友好的新手引导语,输出格式一会儿是 JSON 一会儿是自然语言,解析代码写了一堆 if-else 还是兜不住。后来我换了个思路:把每个职责拆成独立的 Agent,每个 Agent 有自己的系统提示词、自己的输出结构、自己的调用时机。拆完之后,整个系统的可维护性和输出稳定性提升了一个档次。

这篇文章就是把这七个 Agent 的设计思路、提示词写法、结构化输出的处理方式完整拆开讲。涉及的技术栈是Golang + trpc-agent-go + uni-app,前端跑在 uni-app 上(用 HBuilderX 开发),后端用 Golang 做 Agent 编排。如果你正在做 AI Agent 相关的项目,或者想了解多 Agent 架构在具体业务里怎么落地,这篇应该能给你一些可以直接抄的参考。

七个 Agent 分别负责:棋局解析、形势判断、着法推荐、定式识别、复盘讲解、新手引导、对局总结。下面逐个拆。

2. 七个 Agent 的职责边界:拆分的依据和踩过的坑

2.1 拆分原则:按"输出结构"而不是按"功能"来切

很多人拆 Agent 的习惯是按业务功能切——"这个是下棋的,那个是讲解的"。我一开始也这么干,后来发现不对。真正好用的拆分依据是输出结构:如果两个功能的输出格式完全不同,那它们就应该拆成两个 Agent。

举个例子。"形势判断"的输出是一个结构化的评估数据(谁领先多少目、哪块棋薄、哪个区域价值大),而"复盘讲解"的输出是一段自然语言文本("第 23 手这里其实可以考虑小飞挂角……")。这两个如果放在一个 Agent 里,模型会在"输出 JSON"和"输出自然语言"之间反复横跳,解析起来非常痛苦。

所以我的拆分逻辑是:

Agent 名称输出类型调用时机
棋局解析 Agent结构化 JSON(棋盘状态)每次落子后
形势判断 Agent结构化 JSON(评估数据)每 5 手或用户主动请求
着法推荐 Agent结构化 JSON(候选着法列表)用户请求提示时
定式识别 Agent结构化 JSON + 简短文本检测到布局阶段时
复盘讲解 Agent自然语言长文本对局结束后
新手引导 Agent自然语言短文本新手模式下每步
对局总结 Agent结构化 JSON + 文本对局结束后

这个表格是我实际项目里用的,不是理论推导。拆完之后最大的感受是:每个 Agent 的提示词可以写得很短很聚焦,不用再在一个 prompt 里塞七八个任务说明。

2.2 为什么是七个而不是五个或十个

这个问题我被问过好几次。答案是:七个是当前业务需求下的最小完备集。少一个就会有职责重叠,多一个就会有 Agent 闲着。

具体来说,"棋局解析"和"形势判断"必须分开,因为前者是纯客观的(棋盘上哪里有子),后者是带评估的(这个局面谁好)。"着法推荐"和"定式识别"也必须分开,因为定式识别只在布局阶段有用,中盘阶段调它纯属浪费 token。"复盘讲解"和"对局总结"看起来像,但复盘是逐手分析,总结是整体评价,输出粒度完全不同。

提示:拆分 Agent 的时候,先问自己一个问题——"这两个功能的输出,我能不能用同一段解析代码处理?"如果不能,就该拆。

2.3 用 trpc-agent-go 做编排的初步结构

后端我用的是trpc-agent-go这个框架来做 Agent 编排。选它的原因很简单:它原生支持 Golang,和我的后端技术栈一致,而且它对 Agent 的注册、调用链、上下文传递有比较清晰的抽象。

基本的编排结构是这样的:

// 伪代码示意,展示 Agent 注册和调度的核心逻辑 type AgentRegistry struct { agents map[string]Agent } func (r *AgentRegistry) Register(name string, agent Agent) { r.agents[name] = agent } func (r *AgentRegistry) Dispatch(ctx context.Context, agentName string, input AgentInput) (AgentOutput, error) { agent, ok := r.agents[agentName] if !ok { return nil, fmt.Errorf("agent %s not found", agentName) } return agent.Execute(ctx, input) }

每个 Agent 实现统一的Execute接口,输入输出都是结构化的。这样调度层不需要关心具体是哪个 Agent,只需要根据当前棋局状态决定调哪个。

这里有个坑我踩过:不要在 Agent 内部直接调用另一个 Agent。我一开始图方便,在"着法推荐 Agent"里直接调了"形势判断 Agent"来获取评估数据。结果调用链变得极深,调试的时候根本不知道是哪一层出的问题。后来改成所有 Agent 调用都由调度层统一管理,Agent 之间通过共享的棋局状态(存在 context 里)来传递数据,清晰多了。

3. 提示词工程:每个 Agent 的 prompt 到底怎么写

3.1 系统提示词的三段式结构

我所有 Agent 的系统提示词都遵循同一个三段式结构:角色定义 + 任务说明 + 输出约束。这个结构看起来简单,但每一段都有讲究。

以"形势判断 Agent"为例:

[角色定义] 你是一名职业围棋形势判断专家,擅长快速评估局面优劣。 [任务说明] 根据给定的棋盘状态,评估当前局面的形势。你需要判断: 1. 当前谁领先,领先大约多少目 2. 棋盘上哪块棋最薄弱 3. 哪个区域当前价值最大 [输出约束] 严格按照以下 JSON 格式输出,不要添加任何额外文字: { "leader": "black" | "white" | "even", "score_diff": 数字(正数表示黑领先,负数表示白领先), "weakest_group": "描述最薄弱棋块的位置", "most_valuable_area": "描述当前价值最大的区域" }

这个 prompt 的关键在于输出约束部分写得极其明确。我试过只写"请输出 JSON 格式",结果模型有时候会在 JSON 前面加一句"好的,以下是分析结果:",解析直接挂掉。后来我把"不要添加任何额外文字"加进去,并且把完整的 JSON schema 写出来,稳定性好了很多。

3.2 提示词里的"反例"比"正例"更有用

这是我在实际调试中发现的。给模型看正例("好的输出长这样")效果一般,但给反例("不要输出成这样")效果出奇地好。

比如"着法推荐 Agent",我一开始只告诉它"输出候选着法的坐标和理由"。结果模型经常输出一堆它自己编的坐标,有些甚至不在棋盘上。后来我在 prompt 里加了一段:

[错误示例 - 不要这样输出] - 不要输出不在 19x19 范围内的坐标 - 不要推荐已经有棋子的位置 - 不要输出超过 5 个候选着法 - 理由不要超过 20 个字

加了这段之后,无效输出的比例从大概 15% 降到了 3% 以下。这个经验我觉得挺通用的:模型对"不要做什么"的遵循度,往往比"要做什么"更高。

3.3 不同 Agent 的提示词长度差异很大

七个 Agent 的提示词长度差别很大,这个是有意为之的。

"棋局解析 Agent"的提示词最短,大概 100 字就够了,因为它的任务非常明确——把棋盘状态转成 JSON。而"复盘讲解 Agent"的提示词最长,有 800 多字,因为它需要控制讲解的风格、深度、语气,还要避免一些常见的讲解毛病(比如每步都说"这步棋不错"这种废话)。

我的经验是:任务越主观,提示词越长;任务越客观,提示词越短。不要为了统一而把所有提示词写成一样的长度,那是给自己找麻烦。

3.4 提示词版本管理:别把 prompt 硬编码在代码里

这个坑我必须单独说。项目初期我把所有提示词直接写在 Golang 代码里,改一个词就要重新编译部署。后来 Agent 多了,提示词改了十几版,代码里到处都是字符串常量,根本管不过来。

后来我改成了提示词外置:每个 Agent 的提示词存在单独的配置文件里(我用的是 YAML),代码启动时加载。这样改提示词不用重新编译,而且可以做版本对比。

# prompts/position_judge.yaml name: position_judge version: 3 system_prompt: | 你是一名职业围棋形势判断专家... output_schema: type: object properties: leader: type: string enum: [black, white, even] score_diff: type: number

这个改动看起来小,但对迭代效率的提升是巨大的。我后来甚至做了一个简单的 A/B 测试机制,同一个 Agent 可以挂两个版本的提示词,根据用户反馈来选。

4. 结构化输出:让 LLM 的输出能被 Golang 稳定解析

4.1 为什么必须做结构化输出

围棋小程序的前端是 uni-app 做的,后端是 Golang。前端要渲染棋盘、显示评估条、弹出提示,这些都需要确定格式的数据。如果 LLM 输出的是自然语言,前端根本没法用。

我试过让前端去解析自然语言,写正则表达式提取坐标和评估值。结果就是:模型换个说法,正则就匹配不上,前端直接白屏。这个方案在 demo 阶段能跑,上线必挂。

所以结构化输出不是"锦上添花",是必须做的基础设施。

4.2 JSON Schema 约束 + 后置校验双保险

我的做法是两层保险:

第一层是在提示词里写清楚 JSON Schema,让模型知道要输出什么格式。第二层是在 Golang 侧做严格的 schema 校验,解析失败就重试或降级。

type PositionEval struct { Leader string `json:"leader"` ScoreDiff float64 `json:"score_diff"` WeakestGroup string `json:"weakest_group"` MostValuableArea string `json:"most_valuable_area"` } func ParsePositionEval(raw string) (*PositionEval, error) { var eval PositionEval // 先尝试直接解析 if err := json.Unmarshal([]byte(raw), &eval); err != nil { // 尝试提取 JSON 块(处理模型多输出文字的情况) extracted := extractJSONBlock(raw) if err := json.Unmarshal([]byte(extracted), &eval); err != nil { return nil, fmt.Errorf("parse failed: %w", err) } } // 业务校验 if eval.Leader != "black" && eval.Leader != "white" && eval.Leader != "even" { return nil, fmt.Errorf("invalid leader value: %s", eval.Leader) } return &eval, nil }

extractJSONBlock这个函数是我踩坑之后加的。即使提示词里写了"不要添加额外文字",模型偶尔还是会加。这个函数会从输出里找到第一个{和最后一个},把中间的部分提取出来再解析。加了这层之后,解析成功率从 92% 左右提到了 99% 以上。

4.3 解析失败时的降级策略

即使做了两层保险,还是会有解析失败的情况。这时候不能直接报错给用户,要有降级策略。

我的降级策略分三级:

  1. 重试:解析失败后,把错误信息拼回 prompt 里,让模型重新输出一次。这个对"格式错误"类问题很有效。
  2. 降级到简化输出:如果重试还失败,就调用一个"简化版"的 prompt,只要求输出最核心的字段。
  3. 兜底默认值:如果还失败,返回一个安全的默认值,前端显示"暂时无法评估"。

注意:重试次数不要超过 2 次,否则响应时间会变得不可接受。我实测下来,第一次重试能解决 80% 的解析失败,第二次重试只能再解决 10%,性价比很低。

4.4 不同 Agent 的输出结构设计差异

七个 Agent 的输出结构设计思路不一样,这里展开说几个有代表性的。

棋局解析 Agent的输出是纯客观数据,结构最固定:

{ "board_size": 19, "stones": [{"x": 3, "y": 3, "color": "black"}, ...], "last_move": {"x": 4, "y": 4}, "captured": {"black": 0, "white": 2} }

着法推荐 Agent的输出是列表结构,每个候选着法有坐标、评分、理由:

{ "candidates": [ {"x": 5, "y": 5, "score": 0.85, "reason": "扩张势力"}, {"x": 3, "y": 7, "score": 0.72, "reason": "防守薄弱处"} ] }

复盘讲解 Agent的输出是长文本,但我做了分段结构化:

{ "segments": [ {"move_number": 23, "comment": "这步棋可以考虑小飞挂角..."}, {"move_number": 24, "comment": "白棋的应对很稳健..."} ] }

这样前端可以按段落渲染,用户也可以跳转到特定手数。如果直接输出一整段文本,前端就没法做这种交互。

5. Golang 后端的 Agent 调度与并发处理

5.1 为什么选 Golang 做 Agent 编排

选 Golang 做后端编排,主要考虑三点:并发模型简单、部署方便、和 trpc-agent-go 生态契合。

围棋小程序有个特点:用户落子后,可能需要同时触发多个 Agent(比如同时调"棋局解析"和"形势判断")。如果用 Python 做,GIL 会限制并发;用 Golang 的话,goroutine 天然适合这种场景。

func (s *GameService) OnMove(ctx context.Context, move Move) (*MoveResult, error) { var wg sync.WaitGroup var parseResult *BoardState var evalResult *PositionEval var err1, err2 error wg.Add(2) go func() { defer wg.Done() parseResult, err1 = s.agents.Dispatch(ctx, "board_parser", ...) }() go func() { defer wg.Done() evalResult, err2 = s.agents.Dispatch(ctx, "position_judge", ...) }() wg.Wait() if err1 != nil || err2 != nil { // 错误处理 } return mergeResults(parseResult, evalResult), nil }

这段代码是实际项目里用的简化版。两个 Agent 并行调用,总耗时取决于慢的那个,而不是两个相加。

5.2 Agent 调用的超时控制和上下文传递

LLM 调用有个特点:响应时间不稳定。有时候 1 秒返回,有时候 10 秒还在转。如果不做超时控制,一个慢请求会拖垮整个服务。

我的做法是给每个 Agent 调用设置独立的超时时间,并且用 context 传递:

func (a *BaseAgent) Execute(ctx context.Context, input AgentInput) (AgentOutput, error) { ctx, cancel := context.WithTimeout(ctx, a.timeout) defer cancel() resp, err := a.llmClient.Call(ctx, a.buildPrompt(input)) if err != nil { if errors.Is(err, context.DeadlineExceeded) { return nil, ErrAgentTimeout } return nil, err } return a.parseOutput(resp) }

不同 Agent 的超时时间设置不一样。"棋局解析"这种简单任务设 3 秒,"复盘讲解"这种复杂任务设 15 秒。这个值是根据实际压测数据调的,不是拍脑袋定的。

5.3 用 context 传递棋局状态而不是重复传参

七个 Agent 都需要知道当前棋局状态。如果每个 Agent 调用都手动传一遍棋盘数据,代码会非常啰嗦。我的做法是把棋局状态存在 context 里:

type gameContextKey struct{} func WithGameState(ctx context.Context, state *GameState) context.Context { return context.WithValue(ctx, gameContextKey{}, state) } func GetGameState(ctx context.Context) *GameState { state, _ := ctx.Value(gameContextKey{}).(*GameState) return state }

这样 Agent 内部需要棋局状态时,直接从 context 取就行。但要注意:context 里不要放太大的数据,棋盘状态本身不大(19x19 的数组),放进去没问题。但如果你要传整个对局历史,就要考虑用引用或者单独存储。

5.4 并发调用的顺序依赖问题

不是所有 Agent 都能并行调。有些 Agent 有顺序依赖,比如"复盘讲解"必须在"棋局解析"完成之后才能调,因为它需要完整的对局记录。

我在调度层做了一个简单的依赖图:

var agentDependencies = map[string][]string{ "board_parser": {}, "position_judge": {"board_parser"}, "move_recommend": {"board_parser", "position_judge"}, "joseki_recognize": {"board_parser"}, "review_comment": {"board_parser"}, "beginner_guide": {"board_parser", "position_judge"}, "game_summary": {"board_parser", "review_comment"}, }

调度的时候按拓扑顺序执行,同一层级的 Agent 可以并行。这个设计让整个调用链清晰了很多,也避免了"某个 Agent 拿到不完整数据"的问题。

6. uni-app 前端的 Agent 交互设计

6.1 前端怎么"感知"到多个 Agent 的存在

前端是 uni-app 做的,用 HBuilderX 开发。这里有个设计决策:前端要不要知道有七个 Agent?

我的选择是:前端不需要知道。前端只跟一个统一的 API 打交道,后端负责决定调哪些 Agent、怎么合并结果。前端拿到的是一个统一的响应结构:

{ "board_state": {...}, "evaluation": {...}, "hints": [...], "guide_text": "..." }

这样做的好处是前端逻辑简单,后端调整 Agent 拆分时前端不用改。坏处是前端没法做精细的加载状态控制(比如"形势判断加载中"这种)。我后来加了一个pending_agents字段来折中:

{ "board_state": {...}, "pending_agents": ["position_judge", "move_recommend"], "partial_result": true }

前端可以根据这个字段显示"评估中..."的占位符,用户体验好很多。

6.2 流式输出在复盘讲解场景的应用

"复盘讲解 Agent"的输出是长文本,如果等全部生成完再显示,用户要等好几秒。这里我用了流式输出(SSE),前端边接收边渲染。

uni-app 里处理 SSE 需要用uni.request的enableChunked选项:

const task = uni.request({ url: '/api/review/stream', enableChunked: true, success: () => {}, fail: () => {} }) task.onChunkReceived((res) => { const text = decodeChunk(res.data) this.reviewText += text })

这个功能在 HBuilderX 里调试的时候要注意:真机调试和模拟器调试的行为可能不一样。我在模拟器上跑得好好的,真机上 chunk 回调不触发,后来发现是某个版本的兼容问题,升级 HBuilderX 之后解决了。

6.3 新手引导 Agent 的交互节奏控制

"新手引导 Agent"和其他 Agent 不一样,它的输出是即时反馈,用户每走一步都要看到引导语。这就要求它的响应必须快。

我的优化措施有三个:

  1. 提示词极简化:新手引导的 prompt 只有 50 字左右,任务就是"用一句话点评这步棋"。
  2. 用小模型:这个 Agent 我用的是参数量较小的模型,响应速度快很多,质量对新手引导来说够用。
  3. 预生成 + 缓存:常见的新手走法(比如开局几步)的引导语提前生成好,用户走到这些位置直接返回缓存。

这三个措施加起来,新手引导的响应时间从平均 2.5 秒降到了 0.8 秒左右。

6.4 前端错误处理:Agent 挂了怎么办

Agent 调用失败是常态,前端必须优雅处理。我的原则是:任何单个 Agent 失败,都不能让整个页面挂掉。

具体做法是:后端返回的响应里,每个 Agent 的结果是独立的,失败的 Agent 返回null加一个错误标记。前端渲染时跳过失败的模块,显示一个友好的占位。

// 前端渲染逻辑 if (response.evaluation) { this.renderEvaluationBar(response.evaluation) } else { this.showPlaceholder('形势评估暂时不可用') }

这个设计让系统的健壮性提升了很多。以前一个 Agent 超时,整个页面白屏;现在只是某个模块显示"暂时不可用",其他功能正常。

7. 实测中的坑与调优经验

7.1 Token 消耗:七个 Agent 不等于七倍成本

很多人担心拆成七个 Agent 会让 token 消耗暴涨。实测下来,并没有。原因是:

  • 每个 Agent 的提示词更短更聚焦,单次调用的 token 量比"大 prompt"少
  • 不是所有 Agent 每次落子都调用,很多是按需触发
  • 并行调用不增加 token,只增加并发

我统计过一个完整对局(约 200 手)的 token 消耗:拆分前用大 prompt 方案,平均每手 1200 token;拆分后七个 Agent 加起来,平均每手 800 token 左右。反而更省了。

7.2 输出稳定性:从 85% 到 99% 的解析成功率

解析成功率是我最关注的指标。项目初期大概 85%,经过这几轮优化到了 99% 以上:

优化措施成功率提升
初始版本85%
加 JSON Schema 约束90%
加 extractJSONBlock 兜底94%
加反例说明97%
加重试机制99%+

这个表格是我实际记录的,不是估算。每一步优化都有明确的收益。

7.3 响应延迟:并行调用和缓存的组合拳

响应延迟直接影响用户体验。我的优化组合是:

  • 并行调用:无依赖的 Agent 并行执行,节省 40% 左右的时间
  • 结果缓存:相同棋局状态的评估结果缓存 5 分钟,重复请求直接返回
  • 分级加载:先返回快的结果(棋局解析),慢的结果(复盘讲解)异步补充

这套组合下来,用户感知的响应时间从 3-4 秒降到了 1 秒以内。

7.4 提示词迭代:怎么知道改得好不好

提示词改来改去,怎么判断改得好不好?我的做法是建一个测试集:收集 50 个典型的棋局状态,每次改提示词后跑一遍,看输出质量。

质量评估分两个维度:格式正确率(能不能解析)和内容质量(人工打分)。格式正确率可以自动化统计,内容质量我找了几个会下棋的朋友帮忙打分。

这个测试集看起来麻烦,但省了我很多时间。以前改提示词全靠感觉,改完上线才发现问题;现在改完先跑测试集,心里有底。

7.5 成本控制:哪些 Agent 值得用大模型

七个 Agent 不是都用同一个模型。我的分配是:

  • 棋局解析、定式识别:用小模型,任务明确,不需要太强的推理能力
  • 形势判断、着法推荐:用中等模型,需要一定的围棋理解
  • 复盘讲解、对局总结:用大模型,需要生成高质量的自然语言
  • 新手引导:用小模型 + 缓存,追求速度

这个分配让整体成本降低了大概 50%,而用户体验没有明显下降。

8. 一些关于多 Agent 架构的个人体会

做这个项目最大的收获,不是学会了某个框架或某个技巧,而是对"Agent 拆分"这件事有了实感。

我见过很多项目,把 Agent 当成一个万能接口,什么都往里塞。结果就是提示词越来越长,输出越来越不可控,最后变成一个谁都不敢改的黑盒。拆成七个 Agent 之后,每个 Agent 的职责清晰,提示词短,输出稳定,改起来也放心。

另一个体会是:结构化输出不是可选项,是必选项。只要你的 Agent 输出要被程序消费,就必须做结构化。自然语言输出只适合直接给人看的场景,比如复盘讲解的最终展示。但即使是这种场景,我也建议在中间层做结构化,前端渲染时再转成自然语言。

最后说一个技术选型的体会。trpc-agent-go 这个框架不是唯一选择,但它和 Golang 生态的结合确实省了我不少事。如果你也在做类似的项目,选框架的时候优先考虑和你现有技术栈契合度高的,而不是功能最多的。功能可以自己补,但技术栈不契合带来的摩擦成本,是很难补的。

围棋这个场景有个好处:它有明确的规则和评估标准,这让 Agent 的输出质量可以被客观衡量。如果你的场景没有这么明确的评估标准,那在 Agent 拆分和提示词设计上,可能需要更多的试错和人工评估。但核心思路是一样的:按输出结构拆分,用结构化约束输出,用并行和缓存优化性能。这三条在哪个场景都适用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询