📘 项目信息
- 项目名: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 代码的教学级实现。它没有花哨功能——能跑、能改、能读懂是它的全部目标。
本文按源代码 + 设计哲学两个维度做一次重述与解读,重点是:
- ReAct 主循环怎么写(
agent.Run()) - OpenAI SSE 流式怎么解析(
parseSSE) - think 块怎么实时从终端擦掉(
thinkFilterWriter) - 为什么工具要分成”两层”(Skill + 兜底 shell)
- sub-agent 怎么隔离上下文(
spawn_subagent) - 多阶段 pipeline 怎么驱动(
emit_runner+run_<skill>_pipeline) - 未来的功能迭代路线(已经在 roadmap 里写好了)
二、整体目录结构#
react-agent/
├── cmd/react-agent/main.go # CLI 入口:装配 agent + 加载 skills + 注册工具
├── config_example.yaml # 配置模板(含结构化 system_prompt)
├── config.yaml # 本地配置(gitignore)
├── skills/ # Skill 目录(每个子目录 = 一个 Skill)
│ ├── hello/ # 打招呼
│ ├── now/ # 当前时间
│ ├── system_health/ # 系统健康概览
│ ├── idea-spark/ # bridge skill(指向 ResearchStudio 的 idea-spark)
│ ├── markdown/, tencent-docs/, find-skills/ ... # 各种 SKILL.md 桥
│ └── AGENTS.md # skill 元信息
├── internal/
│ ├── agent/ # Agent 本体(与项目无关的可复用框架)
│ │ ├── types.go # 数据结构(Message / ToolCall / ToolDef / ToolHandler)
│ │ ├── llm.go # HTTP 调大模型 + SSE 流式解析
│ │ ├── agent.go # ReAct 循环(Run)
│ │ ├── thinkfilter.go # 实时剥离 思考... 块
│ │ ├── subagent_def.go # spawn_subagent 工具的 schema
│ │ ├── subagent.go # 隔离 sub-agent handler
│ │ └── *_test.go # 单元测试
│ ├── tools/
│ │ ├── shell.go # 兜底工具:run_shell_command
│ │ └── emit_runner.go # 多阶段 pipeline driver
│ ├── config/
│ │ └── config.go # 从 YAML 读配置
│ └── skills/
│ ├── skills.go # Skill 加载器
│ └── tools.go # skills_list / skill_view 工具定义
└── go.mod / go.sumplaintext依赖方向是严格的单向流:
cmd → config / tools / skills → agentplaintextagent 包零外部依赖(除了 stdlib),完全可被其他项目复制过去就用。
三、核心数据结构(internal/agent/types.go)#
整个项目只有 4 个数据结构。先把它们钉在脑子里,后面所有逻辑都在它们之间打转:
type Message struct {
Role string `json:"role"` // system / user / assistant / tool
Content string `json:"content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
Name string `json:"name,omitempty"`
}
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"` // 注意:是 JSON 字符串,要再解析
} `json:"function"`
}
type ToolDef struct {
Type string `json:"type"` // 固定 "function"
Function FunctionSpec `json:"function"`
}
type ToolHandler func(argsJSON string) (string, error) // 工具真正执行:入参 JSON 串,出结果字符串go关键设计:
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。简化版:
func (a *Agent) Run(userInput string) (string, error) {
a.History = append(a.History, Message{Role: "user", Content: userInput})
const deadLoopWindow = 3
recentCalls := make([]string, 0, deadLoopWindow+1)
for step := 0; step < a.MaxSteps; step++ {
// 用 thinkFilterWriter 包一层 a.Out——流式 token 实时过滤 think 块
fw := &thinkFilterWriter{w: a.Out}
msg, err := a.callLLM(fw)
if err != nil { return "", err }
fw.Flush()
msg.Content = stripThinkTags(msg.Content)
// assistant 消息(含 tool_calls)入历史
a.History = append(a.History, msg)
// 模型没调 tool → 它给了最终回答(已流式显示)
if len(msg.ToolCalls) == 0 { return msg.Content, nil }
// 死循环检测:最近 3 步 tool_calls hash 都一样 → 终止
stepHash := digestToolCalls(msg.ToolCalls)
recentCalls = append(recentCalls, stepHash)
if len(recentCalls) > deadLoopWindow { recentCalls = recentCalls[1:] }
if len(recentCalls) == deadLoopWindow && allEqual(recentCalls) {
return msg.Content + "\n\n[Agent: 连续 N 步调了同样工具,疑似死循环,主动终止]", nil
}
// 执行所有 tool_calls,把结果作为 tool 消息回塞历史
for _, tc := range msg.ToolCalls {
observation, _ := a.Tools[tc.Function.Name](tc.Function.Arguments)
a.History = append(a.History, Message{
Role: "tool", Content: observation, ToolCallID: tc.ID, Name: tc.Function.Name,
})
}
}
return "", fmt.Errorf("超过最大步数(%d)", a.MaxSteps)
}go四个设计点很值得展开:
4.1 终止条件三道闸#
按优先级:
- LLM 不调 tool → 给的就是最终答案
- 死循环检测:连续
deadLoopWindow=3步调了同样的 tool、同样的 args → 终止 - 超过
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#
type thinkFilterWriter struct {
w io.Writer
inThink bool
buf []byte // 跨 Write 累积
}
func (f *thinkFilterWriter) Write(p []byte) (int, error) {
f.buf = append(f.buf, p...)
for {
if f.inThink {
end := bytes.Index(f.buf, []byte("</think>"))
if end < 0 { return len(p), nil } // 等更多数据
f.buf = f.buf[end+len("</think>"):]
f.inThink = false
continue
}
start := bytes.Index(f.buf, []byte("<think>"))
if start < 0 {
// 没看到 <think> → 把"不可能是 <think> 前缀"的部分写出去
safe := len(f.buf) - (len("<think>") - 1)
if safe > 0 { f.w.Write(f.buf[:safe]); f.buf = f.buf[safe:] }
return len(p), nil
}
if start > 0 { f.w.Write(f.buf[:start]) }
f.buf = f.buf[start+len("<think>"):]
f.inThink = true
}
}go这是一个有状态的状态机——只在 Write 调用之间维护 inThink 标志 + buf 缓冲。即使 <think> / </think> 被切成多片传过来也能正确处理。
💡 小吴笔记:这里有个细节——“保留最后
len("<think>")-1个字节作为缓冲”。如果 LLM 流式把<think>切成thin|+k>...,光看thin|看不出是不是标签开头,所以要先留着。等下批k>进来合并后才判断。这是写边解析类的代码里最容易踩的坑。
流结束要调 fw.Flush(),否则缓冲里最后那点字符可能不会被显示。
七、Skill 系统(internal/skills/)#
7.1 为什么不全注册成独立 tool#
直觉做法是:每个 skill 注册成一个独立 tool。但有几个问题:
- LLM 一次能看到的 tool 数量有限(4o-mini 是 128 个,但 description 命中率会骤降)
- Function calling 的 tool 选择只看 description 一句话——长 SKILL.md 没用
- 中英文混用场景下 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)goskills_list返回[{name, description}, ...]——LLM 用 description 做粗筛skill_view(name)返回 SKILL.md 全文——LLM 读完做精判
7.2 SKILL.md 解析#
---
name: hello
description: 当用户说"向 XX 打个招呼"或类似的最简单社交场景时调用。返回一段问候语模板。需要执行时模型自行用 run_shell_command 输出。
---
# Hello Skill
...yamlskills.Load(rootDir):
- 扫
./skills/下的子目录 - 每个子目录找
SKILL.md - 解析 frontmatter(
--- ... ---之间的 YAML)→name/description - 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 极简接口#
return agent.ToolDef{
Type: "function",
Function: agent.FunctionSpec{
Name: "run_shell_command",
Description: "在本地 shell 中执行一条命令并返回标准输出+标准错误的合并内容。...",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"command": map[string]interface{}{
"type": "string",
"description": "要执行的 shell 命令字符串",
},
},
"required": []string{"command"},
},
},
}go只有一个参数 command,没有别的——信任 LLM 自己拼命令。
8.2 10 秒超时 + 错误回带输出#
const defaultCommandTimeout = 10 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), defaultCommandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "sh", "-c", args.Command)
out, err := cmd.CombinedOutput()
// 超时优先于错误返回
if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("命令执行超时(超过 %v 已被强制结束)", defaultCommandTimeout)
}
if err != nil {
// 把退出码错误和原始输出都返回给模型,让它能基于输出做下一步决策
if len(out) > 0 {
return "", fmt.Errorf("%w\n命令输出:\n%s", err, out)
}
return "", err
}go两个关键:
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 隔离性设计#
func (a *Agent) SpawnSubagentHandler() ToolHandler {
return func(argsJSON string) (string, error) {
// 1. 读 system prompt 文件
spBytes, _ := os.ReadFile(args.SystemPromptFile)
systemPrompt := strings.TrimSpace(string(spBytes))
// 2. 构造 user message:instructions + 拼接 input files 的内容
var userContent strings.Builder
// ...
// 3. 构造隔离的 chatRequest:空 Tools、空 History、新 system prompt
req := chatRequest{
Model: a.Model,
Messages: []Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userContent.String()},
},
Tools: nil, // sub-agent 不能调任何工具
Stream: true,
}
// ... HTTP 调用、流式解析到 stderr ...
}
}go三个关键设计:
- 不继承 History —— 无对话历史泄漏,避免主 agent 的中间 JSON 干扰 sub-agent 决策
- 不继承 Tools —— sub-agent 用
Tools: nil,不能调任何工具,避免主工具链交叉污染 - 流式 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
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━plaintexthost 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)
}goLLM 只需要调一次 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 { ... }
}
}goinvokeNext 解析 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。
if strings.HasPrefix(trimmed, "/") {
cmd, args := parseSlashCommand(trimmed)
switch cmd {
case "skills":
printSkillList(loadedSkills)
continue
case "":
fmt.Println("用法:/<skill-name> [args...] 或 /skills")
continue
default:
if !hasSkill(loadedSkills, cmd) {
fmt.Printf("未知 skill %q(输入 /skills 看列表)\n", cmd)
continue
}
input = expandSkillCommand(cmd, args, loadedSkills)
}
}goexpandSkillCommand 有两种模式:
- 内联模式(
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-agent | Pipeline driver | 学习曲线 |
|---|---|---|---|---|---|
| react-agent(本项目) | OpenAI Chat Completions | Skill + shell 兜底 | ✅ 隔离 | ✅ emit-runner | 中(要读 Go) |
| LangGraph | 各种 | 自定义 Tool | 节点图 | 节点图 | 高 |
| Claude Agent SDK | Anthropic | Tool + Skill | Agent-as-tool | 自定义 | 中 |
| OpenAI Agents SDK | OpenAI | Function Tool | handoff | 自定义 | 中 |
| smolagents | HuggingFace | @tool | ManagedAgent | 自定义 | 低 |
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 周)#
run_shell_command的超时参数化- 加
timeout_seconds字段,默认 10,跑npm install/docker build也能用
- 加
- token 累计 + cost 估算
- 把每一步的 prompt_tokens / completion_tokens 累加到
Agent.Cost - 用 model 名字查
pricing.json做粗略费用估算
- 把每一步的 prompt_tokens / completion_tokens 累加到
/compactslash 命令- 把 history 里老掉的消息压缩成一段摘要,清掉早期 tool 消息
- 长对话(>100 step)容易撞 context window
- 结构化日志
- 现在只有
log.Printf散落到 stderr - 改成可注入的 logger,支持 JSON 输出方便接入 OTLP
- 现在只有
P1(next 1-2 个月)#
- HTTP 连接池 + 重试
- 用
http.Transport配 MaxIdleConns = 100 - 5xx 自动重试 3 次(指数退避)
- 用
- Tool 并发
- 模型一次返回 N 个 tool_calls 时并发执行(受限于 OpenAI 协议,目前是顺序)
- Sub-agent 能调工具
- 给 spawn_subagent 加
tools_allowlist参数 - 让某些 phase 可以用上 shell
- 给 spawn_subagent 加
- Memory 系统
- 接 SQLite,向量化语义检索
- Agent 跨会话记住”用户偏好 / 已完成的命令 / 学到的 trick”
- 多模态输入
- 现在 user message 只接受 text
- 加
attachments字段,支持图片上传到 vision-capable 模型
- Plan mode
- 第一轮让模型只输出 “I will: 1) … 2) …” 不调工具
- 用户确认后再真正进入 react-loop
P2(next 3-6 个月)#
- MCP 协议
- 实现 MCP 客户端,支持
mcp__server__tool形式的工具 - 直接对接社区现成的 MCP server(filesystem / github / postgres …)
- 实现 MCP 客户端,支持
- Worktree 沙箱
run_shell_command默认在 git worktree 里跑- 危险命令可回滚(虽然 Skill 不允许 rm,但 sandbox 是双保险)
- 交互式 REPL 增强
- 多行输入(用于粘贴长 prompt)
- 历史记录(上下箭头)
- 语法高亮
- Skills Marketplace
react-agent skill install <github-url>一键装第三方 skill- 类似 OpenAI 的官方 Skills 仓库
- TUI 模式
- 用 bubbletea 写个 TUI,支持侧边栏显示 token 消耗 + tool history
- 给重度用户看
P3(探索性,看时机)#
- 多 Agent 协作
- 让多个 react-agent 实例互相调用
- 模拟”主 agent + 工具 agent + 检索 agent”的协作流
- Agent 自进化
- 跑完一段对话后,让模型反思”哪些 tool 调用是浪费的”
- 自动调优 system_prompt
- 对接 Agent Protocol / AGI 协议
- 让 react-agent 兼容 LangGraph / OpenAI Agents 的互操作标准
- 可插拔 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-agentbash跑起来后试这几个,看模型怎么”自己想办法”:
| 输入 | 模型应该会做的事 |
|---|---|
跟我打个招呼 | 调 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。但剥到最底层,主循环还是那个循环:
思考 → 调工具 → 观察 → 再思考 → 再调工具 → 给答案plaintextreact-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 上线时同步刷新对应小节。当前版本对应 commitba43f17 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)
}
}gohello、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 | 说明 |
|---|---|---|
| arXiv | arxiv | 预印本,覆盖广 |
| DBLP | dblp | 计算机会议/期刊索引 |
| OpenAlex | open_alex | 开放替代品,数据全 |
| OpenReview | openreview | NeurIPS / ICLR / ICML 投稿 + 评审 |
| Semantic Scholar | semantic_scholar | 引文 + TL;DR |
| Crossref | crossref | DOI 元数据 |
| Model knowledge | model_knowledge | LLM 自己回忆,不打 API(当 API 都不工作时兜底) |
19.2 并发执行(默认开)#
# 默认:每个 source 起一个独立子进程,**并行**跑
python "$SEARCH" --query "..." --start-year 2024 --end-year 2026python--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)#
# run 目录模板(支持 {skill} 和 {slug} 占位符)
run_dir_template: "skill_run/{skill}/{slug}" # 默认值
# 终止信号正则列表(driver 会用 | 拼起来 + ^ 锚定)
terminal_signals:
- "^DONE\\b"
- "^do_not_generate\\b"
- "^phase_\\d+_failed\\b"
# query injection:driver 是否识别 RUN 字段里的占位符
supports_query_injection: true
query_injection_placeholder: "q1|q2|q3|q4"
# OOD short-circuit:sub-agent 决定 query 太模糊时短路
ood_detection: true
# pipeline 日志路径
log_file: "skill_run/idea-spark/pipeline.log"yaml所有字段都可选,任何缺省都用 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: trueyaml这正解释了为什么 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)#
react-agent/
├── cmd/react-agent/
│ ├── main.go 288 行 REPL 装配 + slash 命令 + 5 类工具注册
│ └── pipeline_spec.go 96 行 skill 自定义 pipeline.yaml 加载器
├── config_example.yaml 80 行 配置模板 + 完整 system_prompt
├── skills/
│ ├── AGENTS.md 185 行 Skill 作者指南(已重写)
│ ├── hello/SKILL.md 17 行 单次调用模式示例
│ ├── now/SKILL.md 30 行 单次调用模式示例
│ ├── system_health/SKILL.md 38 行 单次调用模式示例
│ ├── idea-spark/ pipeline skill(5 阶段 × N sub-agent)
│ │ ├── SKILL.md ~70 行 本职契约
│ │ ├── REACT_AGENT.md 框架适配说明
│ │ ├── pipeline.yaml 25 行 driver 行为自定义
│ │ └── scripts/run.py 1613 行 状态机入口(next-loop)
│ └── paper_search/ 单次调用型 skill(**新增**)
│ ├── SKILL.md 358 行 6 源并发 + 去重 + 排序
│ ├── references/programmatic_api.md 46 行 Python API 文档
│ └── scripts/ 2400+ 行 7 个 source connector + 2 个 selftest
├── internal/
│ ├── agent/
│ │ ├── types.go 44 行 4 个核心数据结构
│ │ ├── llm.go 175 行 HTTP + SSE 解析
│ │ ├── agent.go 186 行 ReAct 主循环 + 死循环检测
│ │ ├── thinkfilter.go 85 行 think 块实时过滤
│ │ ├── subagent_def.go 48 行 spawn_subagent schema
│ │ ├── subagent.go 193 行 隔离 sub-agent handler
│ │ └── *_test.go 280+ 行 单元测试
│ ├── tools/
│ │ ├── shell.go 82 行 run_shell_command 兜底工具
│ │ ├── emit_runner.go 586 行 pipeline driver + emit 解析(含 setupLogger)
│ │ ├── pipeline_config.go 135 行 SkillPipelineConfig + applyDefaults
│ │ └── idea_spark_extras.go 178 行 idea-spark 专属 OOD / query injection helpers
│ ├── config/
│ │ └── config.go 68 行 YAML 配置加载
│ └── skills/
│ ├── skills.go 143 行 Loader + NormalizeSkillName
│ └── tools.go 88 行 skills_list / skill_view 工具实现
└── go.mod / go.sum 1 行依赖(gopkg.in/yaml.v3)plaintextGo 代码量统计(截至 2026-08-17):
- 20 个
.go文件,合计 3,142 行(含注释和空行) - 之前版本 ~1,500 行 → 现在 3,142 行,主要来自:
emit_runner.go350 → 586(+236 行,加了 setupLogger / cfg 流转)- 新增
pipeline_spec.go96 行、pipeline_config.go135 行、idea_spark_extras.go178 行 - 其余增量来自测试与边界处理
- 单次调用型 skill(paper_search)+ pipeline 型 skill(idea-spark)合计 Python 代码 ~12,500 行
- 覆盖率 70%+
运行时产物统一收在 skill_run/<skill>/<slug>/ 下,已 .gitignore。