You don’t need to be a great artist to be great at art.
构建 Agent:从一个循环到通用架构
构建 Agent:从一个循环到通用架构 一次模型调用接收输入并生成输出。完成“修复一个测试失败并验证结果”需要多次调用:模型检查代码、执行操作、读取结果,再决定下一步。把这些调用连接起来,就得到一个最小的 Agent Loop。 本文沿着这段程序的数据和调用路径逐项扩展。每节从一个具体限制出发,加入解决它所需的机制。章节按工程实现依赖推进。 一、一个最小的 Agent 最小 Agent 只做三件事:让模型决定下一步,执行模型选择的工具,再把执行结果交还模型。模型给出最终回答时,循环结束。 TYPESCRIPTasync function runAgent(userInput: string): Promise<string> { const messages: ModelMessage[] = [ { role: 'user', content: userInput }, ] while (true) { const response = await model.generate({ messages, tools: toolRegistry.schemas(), }) messages.push(response.message) if (response.toolCalls.length === 0) { return response.text } for (const call of response.toolCalls) { const result = await toolRegistry.execute(call.name, call.arguments) messages.push({ role: 'tool', toolCallId: call.id, content: result, }) } } } 这个循环已经具备 Agent 的核心反馈结构: TEXT用户目标 -> 模型决策 -> 工具行动 -> 环境观察 -> 模型决策 -> ... -> 最终回答 本文先把这段程序称为最小 Agent:模型选择下一步行动,运行时执行行动,并把环境观察送回模型。模型停止调用 Tool 表示本轮循环结束,测试结果与任务要求共同决定工作是否完成。 模型既可以直接回答,也可以通过搜索、文件、Shell、浏览器或业务 API 观察或改变外部环境。工具结果成为下一轮观察,模型据此修正行动。 第一处问题就在 toolRegistry.execute:模型生成的工具名和参数必须先经过运行时校验。 二、校验并执行 Tool Call 要修复测试失败,模型需要读取源码、搜索符号、修改文件并运行测试。我们先把这些操作注册成 Tool: ...
Go Runtime(十一):Context取消传播、Deadline与Cause
本文源码基线为 Go 1.26.2。这是系列从 runtime 核心延伸到标准库并发机制的收束篇;context 是标准库中的协作式取消协议,借助 Channel、锁、原子操作和 Timer 实现取消传播。源码链接固定到 go1.26.2 tag。 context.Context 在请求调用链中传递三类信息:取消信号、截止时间和请求范围内的值。任务函数通过观察 Done、返回错误和清理资源来响应取消。 本文先建立公开契约和资源所有权,再沿最常见的标准取消树追踪 WithCancel -> propagateCancel -> cancel。自定义 Context 的兼容路径作为边界补充,最后再解释 Deadline、Cause、AfterFunc、WithoutCancel 和 Value。 Context接口表达什么 Context 接口由四个方法组成: GOtype Context interface { Deadline() (deadline time.Time, ok bool) Done() <-chan struct{} Err() error Value(key any) any } 方法 回答的问题 关键语义 Deadline 截止时间及其设置状态 返回时间和 ok 标记 Done 何时收到取消通知 返回只读 Channel;永不取消时可以为 nil Err 当前取消分类 返回 Canceled、DeadlineExceeded 或 nil Value 请求链上携带了什么值 沿父链查找最近的同 key 值 Done 通过关闭 Channel 完成一对多广播,Err 和 Cause 提供取消原因。Context 支持多个 goroutine 并发调用;Value 中对象的并发安全性由对象自身的同步机制保证。 ...
Go Runtime(十):Timer实现
本文源码基线为 Go 1.26.2、Linux/amd64。Go 1.23 重写了 Timer Channel 的可回收性与 Stop/Reset 保证;新语义是否默认生效还取决于主模块的 go 版本和 godebug/GODEBUG 设置,不能只看工具链版本。 Timer 同时服务 time.Sleep、time.Timer、time.Ticker、AfterFunc 和网络 Deadline。到期、Stop、Reset 和 Channel 的语义还会受到模块版本与调试开关影响。 先锁定可观察语义 time.NewTimer 的源码仍写 make(chan Time, 1)。新模式(asynctimerchan=0)下 runtime 借助 hchan.timer 对外呈现同步、容量为零的语义;asynctimerchan=1 恢复 Go 1.23 以前的异步 Timer Channel 行为。因此不能只看 make 参数或 go version 推断 cap(t.C) 和 Stop/Reset 语义。 三种有效模式对程序呈现的差异是: asynctimerchan Channel语义 Stop/Reset返回后的旧值 未到期且不可达的Timer 0 同步,cap(C) == 0 保证不会收到 可以被GC回收 1 Go 1.23以前的异步路径,cap(C) == 1 可能收到 不能在到期或Stop前被GC回收 2 runtime新实现,但呈现异步语义,cap(C) == 1 可能收到 可以被GC回收 这里的“从 Go 1.23 起”是模块语义版本,不只是所用 toolchain 的版本。asynctimerchan 在 internal/godebugs 表中标记为 Go 1.23 变更项,旧于 go 1.23 的主模块默认取旧值 1;go 1.23 及更新主模块默认取新值 0。go.mod/go.work 的 godebug 指令、源码中的 //go:debug 或进程环境 GODEBUG 都可以显式覆盖它。调试 Timer 时必须记录最终构建配置。 ...
Go Runtime(九):Runtime信号量与同步原语
本文源码基线为 Go 1.26.2、Linux/amd64。sync 的公开语义由标准库保证;本文讨论的状态位、队列和 runtime 接口属于版本相关实现,不应被业务代码依赖。 sync.Mutex 的竞争慢路径、RWMutex 的读写协调和 WaitGroup 的等待,最终都需要把 G 停下来并在条件满足时重新放回调度器。它们共享 runtime semaphore,但并不共享完全相同的高层状态机。 先建立边界:runtime semaphore 是“按地址等待与唤醒”的底层设施,不是公开的计数信号量类型。存放在 uint32 地址中的 token 只服务于防丢唤醒和交接协议。 先锁定公开契约 Mutex.Unlock 与之后的 Lock、Once 中 f 的完成与所有 Do 返回、WaitGroup.Done 与它解除的 Wait 都建立文档规定的 happens-before 关系。正确性应依赖这些公开保证,而不是当前源码中的 CAS 顺序、starvation 阈值或 treap 形状。 同理,TryLock 失败不建立同步关系,RWMutex 不支持升级/降级,复制首次使用后的同步对象属于错误用法。源码分析应解释契约如何实现,而不是把私有机制升级为新契约。 一次竞争如何穿过各层 TEXT高层状态机 Mutex/RWMutex/WaitGroup/Cond/Once | 原子快路径与竞争判定 | runtime sema / notifyList | sudog + goparkunlock / goready | GMP scheduler 原子操作避免无竞争路径进入内核或调度器;runtime 队列解决竞争时的睡眠与唤醒;调度器决定被 ready 的 G 何时真正运行。Linux futex 不是这些普通路径的直接一一对应物:runtime 自己调度 G,只有 OS 线程级 runtime 锁和休眠等更底层场景才需要平台同步原语。 ...
Go Runtime(八):Channel与Select
本文源码基线为 Go 1.26.2、Linux/amd64。Channel 的语言语义跨平台一致;文中的字段、函数和行号固定到该版本,后续版本可能调整实现。 Channel 不是“带锁队列”的同义词。它同时实现值传递、goroutine 阻塞与唤醒、close 广播、select 多路竞争,并且要和栈复制、GC、Timer 配合。 理解 Channel 的关键,是区分三种正常数据路径:直接交接、环形缓冲区、等待队列。前两条可以立即完成;进入等待队列才会把当前 G 变为 _Gwaiting。nil Channel 的阻塞操作是没有数据路径的永久 park,属于另一个边界情况。 公开语义与内存模型边界 程序首先依赖的是语言契约,而不是 hchan 的字段。例如下面的读取不需要再给 published 加锁: GOvar published int done := make(chan struct{}) go func() { published = 42 close(done) }() <-done fmt.Println(published) // 42 close(done) synchronized before 因关闭而返回的接收,所以接收之后能够观察到此前对 published 的写入。一般地,一次发送 synchronized before 对应接收完成;对容量为 C 的缓冲 Channel,第 k 次接收 synchronized before 第 k+C 次发送完成;当 C=0 时,还额外有接收发生在对应发送完成之前的保证。 ...
Go Runtime(七):Signal与cgo线程管理
本文源码基线为 Go 1.26.2、Linux/amd64。Signal 的编号、trampoline 和上下文结构具有平台差异;本文只把 Unix/Linux 主线作为具体实现,Windows 异常和 IOCP 回调需要单独对照平台文件。 CPU profile 或崩溃栈可能呈现一个反直觉结果:信号打断了任意指令,C 创建的 pthread 也出现在 Go 调用栈里,但 runtime 仍必须识别当前线程、当前 G 和可扫描的栈。信号和 cgo 都会打破普通 Go 调用链的假设:信号可以异步到达,C 代码可能阻塞、修改线程状态,甚至从一个非 Go 创建的线程回调 Go。 本文先解释异步信号入口,再沿 Go 调 C、C 回调 Go 的调用链前进,最后回答外部线程进入 Go 时 M、g0、gsignal 和 TLS 从哪里来。runtime 必须在这些边界上重新建立可识别的线程、栈和执行现场,同时避免在不安全上下文中执行分配、加锁或调度操作。 异步信号边界 Signal承担哪些职责 Go runtime 使用或处理信号的目的并不相同: 把 SIGINT、SIGTERM 等转交给 os/signal。 将某些同步 fault 转换为 Go panic。 用异步信号抢占长时间运行的 goroutine。 采样 SIGPROF 生成 CPU profile。 在 cgo 场景与 C 安装的 handler 协作。 对致命信号打印 traceback 并退出。 因此不能笼统地说“Go 捕获了信号”。同一个 handler 入口会根据 signal 类型、发生线程和当前 G/M 状态走不同分支。 ...
Go Runtime(六):Netpoll与Syscall
本文源码基线为 Go 1.26.2、Linux/amd64。尤其注意:Go 1.26.2 已不再使用历史上的 _Psyscall 状态;旧文章基于 _Psyscall 描述的状态机不能直接套用到当前源码。 在 trace 中,同样一段看起来像“等待 Read 返回”的代码,可能显示为 G 的 Network wait,也可能伴随 OS 线程增加。区别不在 Go 调用语法,而在底层 fd 能否由 runtime poller 以非阻塞方式管理。 网络等待和阻塞系统调用都会让当前 goroutine 暂时无法继续,但 runtime 的处理方式不同: TEXT非阻塞 fd + netpoll G 等在 runtime 中,M/P 可以继续调度 阻塞 syscall M 等在内核中,runtime 设法把 P 交给其他 M 本文沿这两个可观察结果各追踪一次:可轮询网络 I/O 只 park G,真正阻塞的 syscall 则让 M 停在内核,并促使 runtime 把 P 交给其他 M。理解这一区别,就能解释 Go 为什么能用同步风格编写高并发网络服务,以及为什么某些 syscall 仍会增加线程数量。 ...
Go Runtime(五):GC、写屏障与Pacer
本文源码基线为 Go 1.26.2、Linux/amd64、默认 GOEXPERIMENT。该版本的实验 baseline 已默认启用 Green Tea GC,即 goexperiment.GreenTeaGC == true;可用 GOEXPERIMENT=nogreenteagc 构建旧标记路径。两条路径共享 GC 阶段、根扫描、写屏障、assist、pacer 和 sweep,本篇会明确指出默认 Green Tea 特有的 span 批处理。 gctrace 中的 STW 暂停可能很短,请求尾延迟却仍会在分配突增时升高。原因是 GC 成本不只来自暂停,还包括并发标记占用的 CPU、分配 goroutine 承担的 assist,以及为目标堆大小预留的内存。 Go GC 的核心不是“三色”这个名词,而是在用户 goroutine 持续修改对象图时,怎样并发找出仍然可达的对象,并把这些暂停、CPU 与内存成本控制在目标范围内。本文从“什么条件触发一轮 GC”开始,沿同一轮周期解释工作由谁完成,以及这些成本最终在哪里被程序观察到。 一轮 GC 可以理解为四套机制协作:标记算法判断对象生死,写屏障维护并发正确性,分配 goroutine 通过 assist 偿还标记债务,pacer 控制何时开始以及投入多少资源。 模型与触发 为什么需要GC 函数返回只能自然回收栈帧,无法决定堆对象是否仍被其他对象引用: GOtype Node struct { next *Node } func newNode() *Node { return &Node{} } 对象可能通过全局变量、其他堆对象、goroutine 栈或 runtime 结构继续可达。GC 必须从一组根出发遍历引用图,未被发现的堆对象才可以回收。 ...
Go Runtime(四):内存分配器
本文源码基线为 Go 1.26.2、Linux/amd64、默认 GOEXPERIMENT。对象大小阈值、size class 表和局部快路径可能随版本变化。 本文从两个现象出发:源码里的 new(T) 可能产生零次堆分配,对象失去引用后 RSS 也可能维持原值。第一个现象把我们带入 mallocgc、mcache、mcentral 和 mheap;第二个现象继续追踪 sweep、页分配器与 scavenger。 Go 内存分配器要同时满足三类目标:小对象分配足够快,多 P 并发时减少共享锁竞争,GC 又能由任意堆地址快速定位对象边界和标记元数据。它采用按对象规格分级、按 P 缓存、按页统一管理的结构。 把这些现象对应到当前源码前,必须先分清实验开关:源码中存在字段或函数,不代表默认构建会执行它。 实验(GOEXPERIMENT值) Go 1.26.2默认值 本文中的影响 GreenTeaGC(greenteagc) 启用 部分小对象 span 使用页内 mark/scan bits SizeSpecializedMalloc(sizespecializedmalloc) 禁用 默认仍从通用 mallocgc 分派 RuntimeFreegc(runtimefreegc) 禁用 mcache.reusableNoscan 默认不参与分配 默认值以 internal/buildcfg 的实验 baseline 为准;后文提到关闭的实验路径时会再次标明。 先确认对象是否真的进入堆 new(T) 不等于堆分配,返回局部变量地址也不必机械地等于堆分配;编译器根据值是否可能在当前栈帧结束后继续可达、地址是否暴露给未知调用等条件做逃逸分析,随后内联和标量替换还可能消除分配。 GOpackage alloc type Node struct { next *Node val int } //go:noinline func makeNode(v int) *Node { return &Node{val: v} } BASHgo test -gcflags='all=-m=2' ./... go test -run='^$' -bench=. -benchmem ./... 第一条解释编译器决定,第二条测量最终二进制的每次操作分配。只看源码语法或只看逃逸日志都不足以判断生产路径成本。 ...
Go Runtime(三):Goroutine栈管理
本文源码基线为 Go 1.26.2、Linux/amd64。文中的源码链接固定到 go1.26.2 tag;其他版本和架构可能具有不同的汇编入口、常量或实现细节。 goroutine 能够以较低成本大量创建,一个重要原因是它不需要预留传统线程栈那样大的连续空间。Go 为普通 G 使用可增长的连续栈:空间不足时分配更大的栈、复制仍在使用的部分、修正指针,再从原调用点继续执行。 本文只讨论 goroutine 栈的运行时管理。对象为什么逃逸到堆由编译器决定,堆对象如何分配放在下一篇:内存分配器中。 先观察:普通函数为什么调用morestack 先写一个具有明确栈帧、且不会被内联的函数,保存为 stackcheck.go: GOpackage main //go:noinline func frame(x int) int { var buf [256]byte buf[0] = byte(x) return int(buf[0]) } func main() { println(frame(7)) } 关闭优化和内联,查看编译器生成的汇编: BASHgo tool compile -S -N -l stackcheck.go 在 main.frame 开头可以看到 SP 与栈保护值的比较,以及失败后对 runtime.morestack_noctxt 的调用。一个普通函数为何需要在入口询问 runtime,又如何在栈被替换后重新执行?下面先建立三类栈和 G 的现场模型,再回到另一个实验观察整段活动栈的迁移。-N -l 只用于减少实验干扰,不代表生产构建一定生成相同指令。 三类栈不要混淆 一个 M 至少关联三种执行上下文: ...