wth2jhl.online

Back

📘 项目信息

  • 项目名:react-agent
  • 作者:codedancewth(我自己的仓库)
  • 代码量:~1,500 行 Go(agent + skills + tools + cmd + tests)
  • 地址:https://github.com/codedancewth/react-agent ↗
  • 形态:CLI REPL;可加载 skills/ 下的任意 Skill
  • 协议:OpenAI Chat Completions(兼容 DeepSeek / 通义千问 / GLM / 自建代理)
  • 依赖:仅 gopkg.in/yaml.v3 一个三方包

一、为什么我要从零写一个 ReAct Agent?#

2026 年的 Agent 圈生态已经很丰富——Claude Code、Codex CLI、Devin、Aider、OpenHands、LangGraph……每一个都是成熟产品。但我想搞清楚一件事:一个最小可用的 ReAct Agent 到底由哪几块拼起来? 走完一遍工程化实现后,再看 Claude Code / Codex 这种成熟产品的设计,就能”读懂”了。

这一年半里我用过的 Agent 工具越来越像——shell + 几个高频 Skill + 一个会自己想办法的 LLM。但绝大多数博客只讲”什么是 ReAct”的 prompt loop 那一段,没人讲工程化部分:

  • 流式 SSE 怎么按 token 增量累积到结构化字段?
  • 模型开 thinking mode 时,怎么把 <think>...</think> 块实时从终端擦掉?
  • 模型连续 3 步调同一个工具、传同样的 args 怎么办?
  • 怎么设计”两层工具”——Skill 走 schema,run_shell_command 兜底?
  • 多阶段 pipeline 的”next-loop”模式怎么工程化?
  • LLM 怎么调用”隔离的 sub-agent”做审计与生成对抗分离?

react-agent 就是把这些”工程细节”装进 ~1,500 行 Go 代码的教学级实现。它没有花哨功能——能跑、能改、能读懂是它的全部目标。

本文按源代码 + 设计哲学两个维度做一次重述与解读,重点是:

  1. ReAct 主循环怎么写(agent.Run())
  2. OpenAI SSE 流式怎么解析(parseSSE)
  3. think 块怎么实时从终端擦掉(thinkFilterWriter)
  4. 为什么工具要分成”两层”(Skill + 兜底 shell)
  5. sub-agent 怎么隔离上下文(spawn_subagent)
  6. 多阶段 pipeline 怎么驱动(emit_runner + run_<skill>_pipeline)
  7. 未来的功能迭代路线(已经在 roadmap 里写好了)

二、整体目录结构#

依赖方向是严格的单向流:

cmd → config / tools / skills → agent
plaintext

agent 包零外部依赖(除了 stdlib),完全可被其他项目复制过去就用。

三、核心数据结构(internal/agent/types.go)#

整个项目只有 4 个数据结构。先把它们钉在脑子里,后面所有逻辑都在它们之间打转:

关键设计:

  • Message 是 1:1 映射 OpenAI 协议消息;ToolCall 是嵌套结构,每个 assistant 消息可以挂 0..N 个。
  • ToolDef.Function.Parameters 用 map[string]interface{} 直接塞 JSON Schema,不去维护一个强类型的 spec 结构——LLM 看的是 JSON Schema,越接近原生越不容易出错。
  • ToolHandler 是 func(argsJSON string) (string, error),入参永远是 JSON 字符串(与协议对齐),返回字符串是给模型”读”的人类语言描述。

💡 小吴笔记:把这四个结构背下来,整个 Agent 的工作流就是——LLM 回 Message{ToolCalls: [...]} → 解析 → 执行 ToolHandler → 把结果作为 tool 消息塞回 History → 再喂给 LLM。

四、ReAct 主循环(agent.Run())#

agent.Run() 是整个项目的心脏,~80 行就讲清了 ReAct。简化版:

四个设计点很值得展开:

4.1 终止条件三道闸#

按优先级:

  1. LLM 不调 tool → 给的就是最终答案
  2. 死循环检测:连续 deadLoopWindow=3 步调了同样的 tool、同样的 args → 终止
  3. 超过 MaxSteps → 安全网兜底

MaxSteps 默认 10000,是为了应对复杂多阶段 pipeline(实测需要 30-60+ 步)。死循环检测靠 FNV-1a hash:digestToolCalls 把每个 tool_call 的 name + arguments 拼起来做 64-bit 哈希,3 步一致即视为死循环。纯死循环早就因 token 上限或重复失败报错退出了,这个 3-步检测只是兜底。

4.2 死循环 hash 的细节#

func digestToolCalls(tcs []ToolCall) string {
    h := fnvHashInit()  // 0xcbf29ce484222325 (FNV-1a offset basis)
    for _, tc := range tcs {
        fnvHashUpdate(&h, tc.Function.Name)
        fnvHashUpdate(&h, "|")
        fnvHashUpdate(&h, tc.Function.Arguments)
        fnvHashUpdate(&h, ";")
    }
    return fmt.Sprintf("%x", h)
}
go

为什么用 FNV-1a 而不是 SHA-256?——这是 hot path,每个 step 都跑。FNV-1a 写起来 8 行、SHA-256 要引第三方包,对”判断两个 tool call 是否一致”这种用途,64-bit 碰撞概率已经够低(10⁻¹⁰ 量级)。

4.3 流式输出与历史累积的双轨#

callLLM 同时干两件事:

  • 把每个 content delta 立即写到 w(实现”打字机效果”)
  • 把 content + tool_calls 增量累积成完整 Message 返回

这样历史里始终是完整消息,但用户看到的是逐字流。

4.4 Tool 消息的顺序铁律#

assistant 消息先入历史(包含 tool_calls),tool 消息才能引用它。这是 OpenAI 协议要求,调换顺序就报错。

这一行 a.History = append(a.History, msg) 看起来平淡无奇,但删掉它整个 Agent 都跑不起来。

五、流式 SSE 解析(internal/agent/llm.go)#

OpenAI 的流式协议是 SSE(Server-Sent Events),每个 data: 行是一个 JSON chunk。核心代码 100 行,要点四个:

5.1 强制 Stream: true#

type chatRequest struct {
    Model    string    `json:"model"`
    Messages []Message `json:"messages"`
    Tools    []ToolDef `json:"tools,omitempty"`
    Stream   bool      `json:"stream"`  // 永远 true
}
go

本项目只走流式协议——等模型生成完一次性 dump JSON 在体验上太糟糕。代价是代码复杂点(要处理增量),收益是体验大幅提升。

5.2 错误也可能藏在 SSE 里#

if resp.StatusCode >= 400 {
    body, _ := io.ReadAll(resp.Body)
    return Message{}, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
}
return a.parseSSE(resp.Body, w)
go

流式协议下,错误通常也以 SSE 形式返回(最后一个 chunk 含 error 字段)。但部分厂商在 4xx/5xx 时直接返回 JSON 错误体,所以先看 HTTP 状态码再看 SSE 内部。

5.3 tool_calls.arguments 是分段传过来的#

每个 delta 只携带 tool_calls[].function.arguments 的”片段”:

{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"comm"}}]}}]}
{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"and\":\"ls\""}}]}}]}
{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"-la\"}"}}]}}]}
json

完整参数要按 index 增量累积:

for _, tc := range delta.ToolCalls {
    for len(msg.ToolCalls) <= tc.Index {
        msg.ToolCalls = append(msg.ToolCalls, ToolCall{})
    }
    entry := &msg.ToolCalls[tc.Index]
    if tc.Function.Arguments != "" {
        entry.Function.Arguments += tc.Function.Arguments   // 必须是 +=
    }
}
go

容易踩坑的两个点:

  • arguments 字段是 += 拼接,不能覆盖
  • bufio.Scanner 默认单行 64KB 限制,要手动 Buffer(make([]byte, 0, 64*1024), 1024*1024) 提到 1MB

5.4 Context timeout 双层#

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", a.APIBaseURL, ...)
go

整个调用 2 分钟上限——是兜底网,正常情况靠 http.Client 自己超时。双层 timeout 是 Go HTTP 客户端的标准实践。

六、实时剥离 think 块(thinkfilter.go)#

模型一旦开启 thinking mode,会把 <think>...</think> 块当成正文吐出来。直接给用户看会很丑(几千字”内心戏”),需要实时过滤掉。

6.1 正则兜底:stripThinkTags#

var thinkTagsPattern = regexp.MustCompile(`<think(?:ing)?>[\s\S]*?</think(?:ing)?>`)

func stripThinkTags(s string) string {
    return thinkTagsPattern.ReplaceAllString(s, "")
}
go

用于清洗累积到 msg.Content 里的内容(用于 History 和返回值)。但它不能解决”流式显示”问题——如果等模型生成完再正则,整个 think 块会先一坨怼到终端,再被擦掉。

6.2 流式过滤:thinkFilterWriter#

这是一个有状态的状态机——只在 Write 调用之间维护 inThink 标志 + buf 缓冲。即使 <think> / </think> 被切成多片传过来也能正确处理。

💡 小吴笔记:这里有个细节——“保留最后 len("<think>")-1 个字节作为缓冲”。如果 LLM 流式把 <think> 切成 thin| + k>...,光看 thin| 看不出是不是标签开头,所以要先留着。等下批 k> 进来合并后才判断。这是写边解析类的代码里最容易踩的坑。

流结束要调 fw.Flush(),否则缓冲里最后那点字符可能不会被显示。

七、Skill 系统(internal/skills/)#

7.1 为什么不全注册成独立 tool#

直觉做法是:每个 skill 注册成一个独立 tool。但有几个问题:

  1. LLM 一次能看到的 tool 数量有限(4o-mini 是 128 个,但 description 命中率会骤降)
  2. Function calling 的 tool 选择只看 description 一句话——长 SKILL.md 没用
  3. 中英文混用场景下 description 容易漏匹配

所以 react-agent 走的是 openai-agent-py 的思路:两个工具管所有 skill:

listDef, listHandler := skills.SkillsList(loadedSkills)   // skills_list
viewDef, viewHandler := skills.SkillView(loadedSkills)    // skill_view
a.RegisterTool(listDef, listHandler)
a.RegisterTool(viewDef, viewHandler)
go
  • skills_list 返回 [{name, description}, ...]——LLM 用 description 做粗筛
  • skill_view(name) 返回 SKILL.md 全文——LLM 读完做精判

7.2 SKILL.md 解析#

---
name: hello
description: 当用户说"向 XX 打个招呼"或类似的最简单社交场景时调用。返回一段问候语模板。需要执行时模型自行用 run_shell_command 输出。
---

# Hello Skill
...
yaml

skills.Load(rootDir):

  1. 扫 ./skills/ 下的子目录
  2. 每个子目录找 SKILL.md
  3. 解析 frontmatter(--- ... --- 之间的 YAML)→ name / description
  4. frontmatter 之外的 Markdown 正文作为 Body,供 skill_view 返回给 LLM

frontmatter 校验:

  • name 必须等于目录名(不一致报错)
  • description 必填(让 LLM 做粗筛用的)

7.3 设计哲学:两种 Skill,分层使用#

项目内 3 个”轻量 skill”示范了引用模式:

工具形态适用场景
Skill(推荐)预定义的”做某件事”的能力,参数有 JSON Schema常见、有明确 schema 的领域任务(问时间、看系统状态、打招呼)
run_shell_command(兜底)通用 shell 执行,自己挑命令、自己解析不存在合适 Skill 时的万能回退

为什么不全都写成 Skill?

  • 把”打招呼""查时间”这种”领域明确、参数稳定”的事封装成 Skill,参数有 schema,模型调用更准、错误更好排查。
  • 但任何”查、找、读、算、改”的灵活任务都交给 run_shell_command——它的强大来自”模型自己挑命令、自己看输出、换命令、迭代”,这才是 Agent 的核心能力。
  • 把所有需求都写死成 Skill,等于让模型在一堆固定按钮里挑,没有”自己想办法”的发挥空间。

这就是 Claude Code / Devin / Codex CLI 这类”代码 Agent”的工作方式:给模型一个 shell + 几个高频 Skill,它就能做几乎所有事。

八、run_shell_command 的工程细节(internal/tools/shell.go)#

8.1 极简接口#

只有一个参数 command,没有别的——信任 LLM 自己拼命令。

8.2 10 秒超时 + 错误回带输出#

两个关键:

  • sh -c 包裹,方便模型写管道、重定向、变量替换等 shell 语法
  • 命令失败时把 CombinedOutput 一并返回——LLM 看到错误信息 + 上下文输出,能换种方式重试

10 秒默认对 95% 的命令够用。如果跑长任务可以加 CmdConfig 字段暴露,但目前不需要。

九、spawn_subagent(internal/agent/subagent.go)#

这是 v0.3 之后加的——为了支持 idea-spark 这种多阶段 pipeline。

9.1 为什么需要 sub-agent#

idea-spark 的 5 阶段工作流里,Phase 3 有”adversarial separation”——审计 vs 生成必须用不同的 system prompt 跑。如果让主 agent 自己兼审计,它会偷懒(生成时已经知道答案,审计容易放水)。让一个隔离的 sub-agent 用审计专用 prompt 跑就解决了。

9.2 隔离性设计#

三个关键设计:

  1. 不继承 History —— 无对话历史泄漏,避免主 agent 的中间 JSON 干扰 sub-agent 决策
  2. 不继承 Tools —— sub-agent 用 Tools: nil,不能调任何工具,避免主工具链交叉污染
  3. 流式 token 写到 stderr —— 不污染主 stdout 的 react-loop 显示

主 agent 拿到的 observation 是一行简短的状态:

subagent_done: 写入 /path/to/output.md (1234 字节) | 任务: 审计 idea 的可实现性
plaintext

完整内容已经在文件里了,不需要塞回主对话。

9.3 isPlausibleFilePath 的小细节#

func isPlausibleFilePath(s string) bool {
    if strings.ContainsAny(s, " \t<>") { return false }
    if strings.ContainsAny(s, "/\\") { return true }
    if strings.Contains(s, ".") { return true }
    return false
}
go

用于区分”上游真正传了一个文件路径但文件不存在”vs”上游传的是文本占位符误以为路径”两种情况。后者会在 user message 里写 (已跳过:看起来是文本占位符而非文件路径) 而不是 读文件失败: no such file —— 避免 LLM 困惑。

十、emit_runner:多阶段 pipeline driver(internal/tools/emit_runner.go)#

这是这个项目最”工程化”的一段——把”next-loop”模式的多阶段 skill 通用化。

10.1 问题#

idea-spark 的 scripts/run.py 用”next”状态机吐 emit:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
STATE  : phase_2
STEP   : bottleneck
TYPE   : llm_subagent
PROMPT : /path/to/prompts/phase2_bottleneck.md
INPUT  : /path/to/lit_review.json
OUTPUT : /path/to/bottleneck.json
NOTES  : identify top-3 bottlenecks
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
plaintext

host LLM 需要按 TYPE 分派:

  • llm_subagent → 调 spawn_subagent 隔离执行
  • bash → 跑 RUN 命令
  • 终态 DONE / do_not_generate / phase_X_failed → 停手

问题是:LLM 弱模型在 react-loop 里反复解析 emit 容易卡。要 30-60+ 步才能跑完 5 个 phase,max steps 不够;模型对 emit 格式不熟还会解析错。

10.2 解法:Go driver 自动跑#

// main.go
for _, s := range loadedSkills {
    scriptPath := filepath.Join(s.Dir, "scripts", "run.py")
    if _, err := os.Stat(scriptPath); err != nil { continue }
    pipelineDef, pipelineHandler := tools.RunSkillPipeline(tools.SkillPipelineConfig{
        SkillDir: s.Dir,
        WorkDir:  ".",
        MaxSteps: 10000,
    }, a)
    a.RegisterTool(pipelineDef, pipelineHandler)
}
go

LLM 只需要调一次 run_idea-spark_pipeline(query=...),driver 自动跑完整个 pipeline,不消耗 react-loop 步数。

10.3 driver 内部循环#

for step := 0; step < cfg.MaxSteps; step++ {
    emit, err := invokeNext(cfg, runDir, args.Query)  // 跑 scripts/run.py next
    if err != nil { return ... }

    // 终止状态
    if emit.Terminal != "" { return transcript.String(), nil }

    // 按 emit 类型分派
    switch emit.Type {
    case "llm_subagent":
        if err := handleSubagentEmit(cfg, runDir, emit, a); err != nil { ... }
    case "bash":
        if err := handleBashEmit(cfg, runDir, emit); err != nil { ... }
    }
}
go

invokeNext 解析 emit 块(72 个 ━ 分隔的 JSON-like 字段),handleSubagentEmit 调 a.SpawnSubagentHandler(),handleBashEmit 跑 bash 命令并替换占位符。

💡 小吴笔记:这套机制是通用的——任何 “next-loop” 模式的多阶段 skill(不只 idea-spark)都能用。把 emit 格式固定下来后,driver 代码不需要为每个新 skill 写一遍。

十二、Slash 命令处理(/skills、/<skill-name>)#

main.go 里实现了一个 REPL 级便利:用户输入 /skills 直接打印 skill 列表(不走 LLM,节省一轮);输入 /<skill-name> [args] 展开成强制指令注入 LLM。

expandSkillCommand 有两种模式:

  • 内联模式(idea-spark 这种内容驱动型):把 SKILL.md 全文直接拼进 user message,LLM 手里立即有完整方法论
  • 引用模式(hello / now / system_health):让 LLM 调 skill_view 拿正文,节省 token

idea-spark 还在展开时注入了额外的”反澄清”契约:

⚠️ idea-spark 的核心契约:Never ask the user mid-flow。
- 模糊方向正是本 skill 的本职场景
- 缺失的 intake 字段由你自行合理推断
- 5 阶段 pipeline 跑完之前不要回头追问用户
plaintext

这是从生产事故里学到的——LLM 在 5 阶段中间一追问用户,整个对话就被卡住,必须重启。契约注入是给弱模型兜底的工程手段。

十三、跟主流 Agent 框架的对比#

框架协议工具设计Sub-agentPipeline driver学习曲线
react-agent(本项目)OpenAI Chat CompletionsSkill + shell 兜底✅ 隔离✅ emit-runner中(要读 Go)
LangGraph各种自定义 Tool节点图节点图高
Claude Agent SDKAnthropicTool + SkillAgent-as-tool自定义中
OpenAI Agents SDKOpenAIFunction Toolhandoff自定义中
smolagentsHuggingFace@toolManagedAgent自定义低

react-agent 的定位:教学级。代码量小到一个人周末能读完,每一行都注释了设计动机;功能齐到能跑真活(idea-spark 这种多阶段 pipeline 已经跑通)。想做 Agent 二次开发、定制私有 Agent 框架,可以从这里 fork。

十四、工程上的取舍与已知限制#

14.1 取舍#

  • 只走流式:复杂度 + 50%,响应体验 + ∞
  • 极简外部依赖:YAML 一个,连 HTTP 客户端都用 stdlib——a.Run 起来就能跑
  • Skill 用目录而不是文件:天然支持 skills/<name>/run.sh 这种可执行 skill 模式(目前没用,但留好扩展点)
  • 不抽象 tool 出参类型:返回一律 string,简单到难以写错

14.2 已知限制#

限制影响缓解
HTTP 客户端是 stdlib 默认没有连接池,长跑会重复握手本地 REPL 场景不明显
没有 streaming 中途取消想 Ctrl-C 停掉 agent 不优雅context.WithCancel 已经预留
run_shell_command 10 秒超时跑 go build 这种长任务会 kill增加 timeout_seconds 参数
没有 tool 并发一次只能等一个 tool 返回OpenAI 协议里多个 tool_call 也是顺序执行
sub-agent 不能调任何工具不能”在 sub-agent 里再 spawn 一个 sub-agent”极少见;想用可以放开
没做 rate limit / cost tracking用着爽但费用无感后期接 callback

十五、未来功能迭代 Roadmap#

这部分是从 react-agent 的 issue 列表 + 自己的 todo 里攒出来的,按”实现难度 + 实际收益”排序。

P0(next 1-2 周)#

  1. run_shell_command 的超时参数化
    • 加 timeout_seconds 字段,默认 10,跑 npm install / docker build 也能用
  2. token 累计 + cost 估算
    • 把每一步的 prompt_tokens / completion_tokens 累加到 Agent.Cost
    • 用 model 名字查 pricing.json 做粗略费用估算
  3. /compact slash 命令
    • 把 history 里老掉的消息压缩成一段摘要,清掉早期 tool 消息
    • 长对话(>100 step)容易撞 context window
  4. 结构化日志
    • 现在只有 log.Printf 散落到 stderr
    • 改成可注入的 logger,支持 JSON 输出方便接入 OTLP

P1(next 1-2 个月)#

  1. HTTP 连接池 + 重试
    • 用 http.Transport 配 MaxIdleConns = 100
    • 5xx 自动重试 3 次(指数退避)
  2. Tool 并发
    • 模型一次返回 N 个 tool_calls 时并发执行(受限于 OpenAI 协议,目前是顺序)
  3. Sub-agent 能调工具
    • 给 spawn_subagent 加 tools_allowlist 参数
    • 让某些 phase 可以用上 shell
  4. Memory 系统
    • 接 SQLite,向量化语义检索
    • Agent 跨会话记住”用户偏好 / 已完成的命令 / 学到的 trick”
  5. 多模态输入
    • 现在 user message 只接受 text
    • 加 attachments 字段,支持图片上传到 vision-capable 模型
  6. Plan mode
    • 第一轮让模型只输出 “I will: 1) … 2) …” 不调工具
    • 用户确认后再真正进入 react-loop

P2(next 3-6 个月)#

  1. MCP 协议
    • 实现 MCP 客户端,支持 mcp__server__tool 形式的工具
    • 直接对接社区现成的 MCP server(filesystem / github / postgres …)
  2. Worktree 沙箱
    • run_shell_command 默认在 git worktree 里跑
    • 危险命令可回滚(虽然 Skill 不允许 rm,但 sandbox 是双保险)
  3. 交互式 REPL 增强
    • 多行输入(用于粘贴长 prompt)
    • 历史记录(上下箭头)
    • 语法高亮
  4. Skills Marketplace
    • react-agent skill install <github-url> 一键装第三方 skill
    • 类似 OpenAI 的官方 Skills 仓库
  5. TUI 模式
    • 用 bubbletea 写个 TUI,支持侧边栏显示 token 消耗 + tool history
    • 给重度用户看

P3(探索性,看时机)#

  1. 多 Agent 协作
    • 让多个 react-agent 实例互相调用
    • 模拟”主 agent + 工具 agent + 检索 agent”的协作流
  2. Agent 自进化
    • 跑完一段对话后,让模型反思”哪些 tool 调用是浪费的”
    • 自动调优 system_prompt
  3. 对接 Agent Protocol / AGI 协议
    • 让 react-agent 兼容 LangGraph / OpenAI Agents 的互操作标准
  4. 可插拔 LLM provider
    • 现在硬编码 OpenAI Chat Completions
    • 抽象成 Provider 接口,支持 Anthropic / Gemini / 本地 llama.cpp

不打算做的#

  • ❌ 拖拽式可视化编排 —— 跟教学定位冲突
  • ❌ 云端 Agent 服务 —— 偏运维,自己跑就好
  • ❌ 多租户 / 权限系统 —— 单机 CLI 用不上

十六、动手复刻的最小路径#

如果你想自己 fork 一份:

git clone https://github.com/codedancewth/react-agent
cd react-agent
cp config_example.yaml config.yaml
vim config.yaml   # 填 api_key + api_base + model
go run ./cmd/react-agent
bash

跑起来后试这几个,看模型怎么”自己想办法”:

输入模型应该会做的事
跟我打个招呼调 Skill hello(参数自动填)
现在几点了?调 Skill now
帮我看看系统健康调 Skill system_health
算一下 (123+456)*7调 run_shell_command 跑 echo $(((123+456)*7))
我电脑内存还剩多少?调 run_shell_command 跑 vm_stat 或 sysctl hw.memsize
列出当前目录所有 .go 文件调 run_shell_command 跑 find . -name "*.go"
什么是 ReAct?不调任何工具,直接回答
/skills列出所有 skill(不走 LLM)
/idea-spark 智能体方向展开 idea-spark 全文并启动 emit-runner

加 --debug 参数启动可看 tool 调用详情,写到 logs/agent.log。

十七、写在最后:为什么 ReAct 还在用#

2026 年的 Agent 已经远远不只”prompt + tool call loop”那么简单了——Claude Code 有 sub-agent、MCP、TodoList、文件锁定、worktree 沙箱;Codex CLI 有 streaming patch、auto-test、approval gate。但剥到最底层,主循环还是那个循环:

思考 → 调工具 → 观察 → 再思考 → 再调工具 → 给答案
plaintext

react-agent 把这个循环 + 工程细节(流式 / think 过滤 / 死循环检测 / 两层 Skill / sub-agent / pipeline driver)都装进 ~1,500 行 Go 代码,让一个想搞懂 Agent 的人周末就能读完。

至于未来——上面 Roadmap 写得很清楚了,先把 P0 解决(timeout / cost / compact / 结构化日志),再逐步加 MCP / 沙箱 / Skills marketplace。我希望这个项目能像”Agent 圈的 SQLite”——够小、够简单、够独立,可以 fork 可以抄可以改。

项目仍在积极维护中,欢迎 PR、issue、fork。


📅 本文状态:本文会随 react-agent 仓库持续迭代,重大 feature 上线时同步刷新对应小节。当前版本对应 commit ba43f17 feat add skill info(在 191d54e feat add sub-agent 基础上新增了 pipeline.yaml 自定义、paper_search 全文检索 skill、Skill 作者指南重写等)。后续 P0 / P1 完成后会有 v2 版本更新。

💬 讨论:如果你 fork 后改了有意思的东西,或者发现 bug / 有 feature 提议,欢迎在仓库 issue 区讨论。


增量更新(2026-08-17):feat add skill info#

下面是 191d54e feat add sub-agent → ba43f17 feat add skill info 这一轮新增的内容,前面的章节描述的设计保持不变,只在这里补充。

十八、Skill 系统重构(pipeline 模式 vs 单次调用模式)#

skills/AGENTS.md 在这一轮被整体重写,把 skill 模式从”只有一个 SKILL.md”细分成了两种正交模式,并且明确自动注册 run_<skill>_pipeline 工具的判定条件。

18.1 判定标准:看 scripts/run.py 在不在#

模式触发条件启动时自动注册的 skill 工具
单次调用模式skills/<name>/SKILL.md 存在,没有 scripts/run.py只通过 skill_view(name) 让 LLM 拿到正文,LLM 自己用 run_shell_command 跑
pipeline 模式skills/<name>/scripts/run.py 存在自动注册 run_<skill>_pipeline tool,driver 接管全部调度

启动时的扫描逻辑(简化):

for _, s := range loadedSkills {
    // 单次调用模式走默认的 skill_view 即可
    scriptPath := filepath.Join(s.Dir, "scripts", "run.py")
    if _, err := os.Stat(scriptPath); err == nil {
        // pipeline 模式:自动注册 run_<skill>_pipeline
        def, h := tools.RunSkillPipeline(tools.SkillPipelineConfig{
            SkillDir: s.Dir,
            WorkDir:  ".",
            MaxSteps: 10000,
        }, a)
        a.RegisterTool(def, h)
    }
}
go

hello、now、system_health、paper_search 都是单次调用模式(虽然 paper_search 自己内部很复杂,但对外只暴露 SKILL.md + 一个 Python CLI,没有 scripts/run.py,所以 LLM 自己用 run_shell_command 调)。只有 idea-spark 是 pipeline 模式。

18.2 SKILL.md 写法纪律#

新 skills/AGENTS.md 把”什么事归 SKILL.md、什么事归 REACT_AGENT.md / pipeline.yaml”彻底切清楚了:

  • SKILL.md 是 skill 的本职契约——写”我能干什么、怎么调”,不要写 run_shell_command / run_<skill>_pipeline 这种 react-agent 框架特有的字眼
  • REACT_AGENT.md(可选)写框架适配细节,跟 SKILL.md 解耦
  • pipeline.yaml(可选)覆盖 driver 的默认行为
  • description 字段是 LLM 在 skills_list 粗筛时的唯一依据,双语触发词稳妥(中英文都列)

18.3 name 字段规范化#

Loader 新加了 NormalizeSkillName,把 hyphen/underscore 等价化:

// loader 走 NormalizeSkillName 做比较
// 所以 paper-search 和 paper_search 任选一种
go

对应到用户侧就是 slash 命令也支持 hyphen 等价:/paper-search ≡ /paper_search。


十九、paper_search Skill:6 源并发 + 去重 + 排序(单次调用模式代表)#

这一轮加了一个纯单次调用的复杂 skill——skills/paper_search/。它的设计哲学跟 idea-spark 正好相反:虽然内部流程复杂,但对外只暴露一个 Python CLI,LLM 用 run_shell_command 调一次就拿到结构化结果,不需要 driver。

19.1 6 个数据源 + 1 个 LLM 召回#

来源Key说明
arXivarxiv预印本,覆盖广
DBLPdblp计算机会议/期刊索引
OpenAlexopen_alex开放替代品,数据全
OpenReviewopenreviewNeurIPS / ICLR / ICML 投稿 + 评审
Semantic Scholarsemantic_scholar引文 + TL;DR
CrossrefcrossrefDOI 元数据
Model knowledgemodel_knowledgeLLM 自己回忆,不打 API(当 API 都不工作时兜底)

19.2 并发执行(默认开)#

# 默认:每个 source 起一个独立子进程,**并行**跑
python "$SEARCH" --query "..." --start-year 2024 --end-year 2026
python

--no-parallel 仍然隔离 worker,但串行执行(罕见用)。

19.3 去重 + 相关性排序(默认输出)#

CLI 默认不是按 source 分组,而是 dedupe + rank 成单一列表:

  • 跨源重复按 DOI → arXiv id → 归一化 title 合并
  • 合并后保留最高信号源的字段、最大引用数、加 Sources: 字段标来源
  • 每篇打一个词法相关性分数
  • 标题里有 survey / review 的打 [survey] 标签,沉底但不丢弃(用户做 related work 时漏一篇 survey 比重复工作更糟)
  • 加 --min-score N 开启”按分数丢弃”——CLI 必须打印丢了多少

--raw 切回”按 source 分组”的旧输出格式,纯 debug 用。

19.4 给 LLM 的展示规范(SKILL.md 明确写死的)#

SKILL.md 里硬性规定了 LLM 拿到结果后的展示模板:

Why full recall matters: users invoking this skill are doing literature reviews, related-work surveys, or prior-art checks. The value comes from seeing the complete set of hits — a missed paper can mean a missed citation or a duplicated research effort. Summaries are meant to augment the full tables, not replace them, so don’t collapse results into a digest “to save space.” The user can skim; they can’t un-skip a paper they never saw.

这是给 LLM 的强约束:不要”为了省事把表格压成摘要”。这种”skill 契约里写明 LLM 行为”的写法,值得借鉴——把”该怎么展示”从 system_prompt 移到 skill 自身,出问题时定位更快。


二十、pipeline.yaml:让 skill 自己配置 driver#

之前 SkillPipelineConfig 字段都是 main.go 里硬编码的;这一轮抽成 pipeline.yaml,skill 可以放在自己目录下覆盖默认。

20.1 schema(PipelineSpec in cmd/react-agent/pipeline_spec.go)#

所有字段都可选,任何缺省都用 driver 的内置默认。

20.2 idea-spark 的实际配置#

skills/idea-spark/pipeline.yaml 的内容(摘录):

terminal_signals:
  - "^DONE\\b"
  - "^do_not_generate\\b"
  - "^phase_\\d+_failed\\b"
supports_query_injection: true
query_injection_placeholder: "q1|q2|q3|q4"
ood_detection: true
yaml

这正解释了为什么 emit_runner 之前”读不到这些字段就跑默认”——现在 driver 会主动读 pipeline.yaml,pipeline_spec.go 在 main.go 启动时调 loadPipelineSpec(skillDir),把 YAML 转成 PipelineSpec,再覆盖到 SkillPipelineConfig。


二十一、emit_runner 内部重构 + 抽离 idea-spark 专属逻辑#

internal/tools/emit_runner.go 从 350 行变成 更小的通用 driver(配合新增的 idea_spark_extras.go 178 行),关键变化:

21.1 抽离 OOD detection 到 idea_spark_extras.go#

之前 emit_runner.go 里直接 hardcode “OOD = {"ood":true,...}” 的检测逻辑。现在:

// internal/tools/idea_spark_extras.go
// ── idea-spark 专属 helpers ──────────────────────────────────
//
// 这些工具是给 idea-spark 用的 OOD short-circuit + query injection 逻辑。
// 其他 skill 不需要用,driver 在 OODDetection=false / SupportsQueryInjection=false
// 时不会调它们。
//
// 抽到这个文件不是为了"未来复用",而是为了让 emit_runner.go 只保留通用调度逻辑。
// 当其他 skill 想要类似能力时,自己实现一份同名 helper 即可。
go

抽离动机是emit_runner.go 注释里写的”为了保持通用 driver 干净,OOD / query injection 是 idea-spark 自己的约定”。这种”先通用,再抽特例”的演进路径很值得学习——不要为了未来复用而设计,先做对眼前的”通用”,再在特例出现时抽走。

21.2 新增 pipeline_config.go 承载 SkillPipelineConfig#

把配置字段从 emit_runner.go 里独立成 internal/tools/pipeline_config.go,跟 driver 解耦。pipeline_spec.go(在 cmd/ 里)负责加载 YAML 并填充 SkillPipelineConfig,driver 只接受已经填好的 config。

21.3 setupLogger() 让 pipeline 有独立日志#

之前所有 pipeline 日志都走 stderr;现在每个 skill 有自己的 LogFile(默认 ./skill_run/<skill>/pipeline.log),失败时降级到主 log 不阻塞。


二十二、运行时产物统一收在 skill_run/ 下#

之前 idea-spark 的产物散落在 skills/idea-spark/phaseN/;现在统一改用 skill_run/:

skill_run/
├── <skill-name>/
│   ├── pipeline.log         # pipeline 模式才有,跨多次 run append
│   └── <run-slug>/          # kebab-case,从用户 query 蒸馏
│       ├── phaseN/...       # pipeline 模式
│       └── allinone.md      # 单次模式
plaintext
  • <run-slug>:loader 帮你 slugify(平安夜的烟花 → ping-an-ye-de-yan-hua 这种)
  • 想换路径直接覆盖 pipeline.yaml 的 run_dir_template 或 log_file
  • skill_run/ 已 .gitignore

这个改动看着小,意义不小:所有 skill 的产物都收一个顶层目录,清理 / 备份 / 跨项目 grep 都简单一个数量级。


二十三、新增 skill 作者的 checklist#

skills/AGENTS.md 末尾给了一份很可执行的 checklist,直接抄过来:

新增一个 skill 时:

  • 决定模式:单次调用 vs pipeline
  • 单次模式:写 SKILL.md 即可。description 写得让 LLM 容易想到
  • pipeline 模式:写 SKILL.md + scripts/run.py;按需加 pipeline.yaml 覆盖默认
  • (可选)写 REACT_AGENT.md 说明框架特定的适配细节
  • 重启 agent —— main.go 启动时会自动扫描 skills/ 并注册
  • 验证:/skills 看到了、/your-skill 能唤醒、跑出来产物在 skill_run/<name>/<slug>/

二十四、新增的”不要做”清单#

同样摘自 skills/AGENTS.md:

  • ❌ 不要在 SKILL.md 里写 run_shell_command(这会污染 skill 的本职契约)
  • ❌ 不要把 skill_run/ 提交到 git(已 gitignore)
  • ❌ 不要 hardcode API key 在任何 skill 里——skill 应该是纯 LLM 知识
  • ❌ 不要在 description 里写”用 run_<skill>_pipeline”——LLM 看到 description 还是调用 skill_view 才能拿到工具名

这第三条特别值得划重点:skill 应该是纯 LLM 知识。paper_search 全程靠公开 API + LLM 召回,没有任何需要 key 才能跑的部分——这个约束让 skill 可以无脑 git clone 就能用,大幅降低分享摩擦。


附录 A:完整文件清单(截至 2026-08-17,commit ba43f17)#

Go 代码量统计(截至 2026-08-17):

  • 20 个 .go 文件,合计 3,142 行(含注释和空行)
  • 之前版本 ~1,500 行 → 现在 3,142 行,主要来自:
    • emit_runner.go 350 → 586(+236 行,加了 setupLogger / cfg 流转)
    • 新增 pipeline_spec.go 96 行、pipeline_config.go 135 行、idea_spark_extras.go 178 行
    • 其余增量来自测试与边界处理
  • 单次调用型 skill(paper_search)+ pipeline 型 skill(idea-spark)合计 Python 代码 ~12,500 行
  • 覆盖率 70%+

运行时产物统一收在 skill_run/<skill>/<slug>/ 下,已 .gitignore。

[持续更新中] Go 写的 ReAct Agent:1.5k 行流式+Skill+Sub-Agent
https://wth2jhl.online/blog/react-agent-go-react-pattern/
Author 小吴同学
Published at August 13, 2026