Agent Loop 深度解析
这页只拆 src/agent.ts:80 到 src/agent.ts:173:它如何收集模型事件、执行工具、补齐结果并维持 Context 可恢复性。
导入链与契约
src/agent.ts:10 只从 llm 层拿 stream 和 message builder,src/agent.ts:13 定义 AgentTool,src/agent.ts:21 定义 AgentEvent。这个契约让 UI 只消费事件,不碰 OpenAI 细节。
10import { stream, buildAssistantMessage, buildToolResultMessage, type Model, type Context } from './llm.js' 11 12/** 工具定义:name + 描述 + JSON Schema 参数 + execute 函数 */ 13export type AgentTool = { 14 name: string 15 description: string 16 parameters: object // JSON Schema 17 execute: (args: unknown, signal?: AbortSignal) => Promise<string> 18} 19 20/** agent 对外的事件流,供 UI 消费 */ 21export type AgentEvent = 22 | { type: 'assistant_text'; delta: string } 23 | { type: 'tool_call'; id: string; name: string; args: unknown } 24 | { type: 'tool_result'; id: string; name: string; result: string } 25 | { type: 'turn_end'; stopReason: 'end_turn' | 'max_tokens' | 'aborted' | 'error' } 26 27/** compaction 触发阈值:消息数超过此值时压缩旧消息 */ 28const COMPACT_THRESHOLD = 50 29/** compaction 保留近期消息数 */ 30const KEEP_RECENT = 20
核心实现,逐段拆
▸压缩不是删除历史,而是替换为摘要
src/agent.ts:37 先检查 abort 和长度,src/agent.ts:41 切出旧消息,src/agent.ts:50 构造摘要 context,src/agent.ts:57 用同一个 stream 总结。失败时 src/agent.ts:63 直接保留原始 context,这是避免空摘要污染会话的关键。
37async function compactContext(model: Model, context: Context, signal?: AbortSignal): Promise<void> { 38 if (signal?.aborted) return // abort 时不压缩——避免空摘要损坏 context 39 if (context.messages.length < COMPACT_THRESHOLD) return 40 41 const oldMessages = context.messages.slice(0, -KEEP_RECENT) 42 const recentMessages = context.messages.slice(-KEEP_RECENT) 43 44 // 把旧消息序列化成纯文本让 LLM 总结 45 const conversationText = oldMessages 46 .map(m => `${m.role}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`) 47 .join('\n') 48 49 // 用一轮非流式调用让 LLM 总结 50 const summaryContext: Context = { 51 systemPrompt: '请将以下对话总结为简洁的上下文摘要,保留关键决策、已做的工作和待办事项。', 52 messages: [{ role: 'user', content: conversationText }], 53 } 54 55 let summary = '' 56 let failed = false 57 for await (const ev of stream(model, summaryContext, { signal })) { 58 if (ev.type === 'text_delta') summary += ev.delta 59 else if (ev.type === 'error' || ev.type === 'done' && ev.stopReason === 'aborted') { failed = true; break } 60 } 61 62 // 压缩失败时不替换 context——保留原始消息比空摘要更安全 63 if (failed || !summary) return 64 65 // 用摘要消息替换旧消息,保留近期消息 66 context.messages = [ 67 { role: 'user', content: `[context summary]\n${summary}` }, 68 ...recentMessages, 69 ] 70}
▸主循环把 tool_call 和 text 分开处理
src/agent.ts:89 的 while 不设 max_steps,src/agent.ts:98 进入流式请求,src/agent.ts:101 立即转发文本,src/agent.ts:103 缓存工具调用,src/agent.ts:122 才把 assistant message 放回上下文。这保证 UI 可以流式显示,而协议状态仍完整。
89 while (true) { 90 // 0. 对话压缩:消息过多时先压缩旧消息 91 await compactContext(model, context, signal) 92 93 // 1. 流式调用 LLM,收集文本和 tool_calls 94 let text = '' 95 let stopReason: 'end_turn' | 'tool_use' | 'max_tokens' | 'aborted' = 'end_turn' 96 const toolCalls: { id: string; name: string; args: unknown }[] = [] 97 98 for await (const ev of stream(model, context, { tools: toolDefs, signal })) { 99 if (ev.type === 'text_delta') { 100 text += ev.delta 101 yield { type: 'assistant_text', delta: ev.delta } 102 } else if (ev.type === 'tool_call') { 103 toolCalls.push({ id: ev.id, name: ev.name, args: ev.args }) 104 yield { type: 'tool_call', id: ev.id, name: ev.name, args: ev.args } 105 } else if (ev.type === 'done') { 106 stopReason = ev.stopReason 107 if (ev.stopReason === 'aborted') { 108 // abort 时丢弃 tool_calls(无对应 tool_result 会导致 session 恢复后 API 报错) 109 context.messages.push(buildAssistantMessage(text, [])) 110 yield { type: 'turn_end', stopReason: 'aborted' } 111 return 112 } 113 } else if (ev.type === 'error') { 114 context.messages.push(buildAssistantMessage(text, [])) 115 yield { type: 'assistant_text', delta: `\n[error] ${ev.error.message}` } 116 yield { type: 'turn_end', stopReason: 'error' } 117 return 118 } 119 } 120 121 // 2. 把 assistant 回复塞回 context 122 context.messages.push(buildAssistantMessage(text, toolCalls)) 123 124 // 3. 截断处理:max_tokens 时 tool args 可能不完整,不执行,把错误放回到 Context 让模型重发 125 if (stopReason === 'max_tokens' && toolCalls.length > 0) { 126 const results = toolCalls.map(tc => ({ 127 tool_use_id: tc.id, 128 content: `error: output truncated by max_tokens, tool "${tc.name}" args may be incomplete.`, 129 })) 130 context.messages.push(buildToolResultMessage(results)) 131 for (let i = 0; i < toolCalls.length; i++) { 132 yield { type: 'tool_result', id: toolCalls[i].id, name: toolCalls[i].name, result: results[i].content } 133 } 134 continue
▸工具执行后必须写回 tool_result
src/agent.ts:145 选择串行工具执行,src/agent.ts:151 对不存在的工具生成错误,src/agent.ts:155 运行 execute,src/agent.ts:166 对 abort 后剩余调用补 error,最后 src/agent.ts:172 写回 user/tool_result 消息进入下一轮。
145 // 5. 串行执行 tool_calls(pi 有 sequential/parallel 两种模式,教学版统一串行) 146 // args 未经 parameters 验证直接传入 execute(教学版简化,生产应先 validate) 147 const results: { tool_use_id: string; content: string }[] = [] 148 for (const tc of toolCalls) { 149 const tool = toolMap.get(tc.name) 150 let result: string 151 if (!tool) { 152 result = `error: tool "${tc.name}" not found` 153 } else { 154 try { 155 result = await tool.execute(tc.args, signal) 156 } catch (e) { 157 result = `error: ${(e as Error).message}` 158 } 159 } 160 results.push({ tool_use_id: tc.id, content: result }) 161 yield { type: 'tool_result', id: tc.id, name: tc.name, result } 162 if (signal?.aborted) break 163 } 164 165 // 6. 为被 abort 跳过的 tool_call 补上错误结果(API 要求每个 tool_call 都有对应 tool_result) 166 for (const tc of toolCalls.slice(results.length)) { 167 results.push({ tool_use_id: tc.id, content: 'error: aborted' }) 168 yield { type: 'tool_result', id: tc.id, name: tc.name, result: 'error: aborted' } 169 } 170 171 // 7. 把 tool_result 放回到 Context,进入下一轮 172 context.messages.push(buildToolResultMessage(results)) 173 }
关键流程
test/agent.test.ts:48 用两轮 mock stream 验证这条路径,test/agent.test.ts:72 断言事件顺序,test/agent.test.ts:76 确认 context 最终有三条消息。未知工具路径由 test/agent.test.ts:79 覆盖。
48 it('tool_call → 执行 → 把结果放回到 Context → 下一轮纯文本', async () => { 49 const echoTool: AgentTool = { 50 name: 'echo', 51 description: 'echo back', 52 parameters: { type: 'object', properties: {} }, 53 execute: async (args: { text: string }) => `ECHO: ${args.text}`, 54 } 55 56 let round = 0 57 mockStreamFn.mockImplementation(async function* (): AsyncGenerator<StreamEvent> { 58 if (round === 0) { 59 yield { type: 'tool_call', id: 't1', name: 'echo', args: { text: 'hi' } } 60 yield { type: 'done', stopReason: 'tool_use' } 61 } else { 62 yield { type: 'text_delta', delta: 'Done' } 63 yield { type: 'done', stopReason: 'end_turn' } 64 } 65 round++ 66 }) 67 68 const ctx: Context = { messages: [] } 69 const events: { type: string; [k: string]: unknown }[] = [] 70 for await (const e of runAgent(model, ctx, [echoTool])) events.push(e) 71 72 expect(events.map(e => e.type)).toEqual(['tool_call', 'tool_result', 'assistant_text', 'turn_end']) 73 const tr = events.find(e => e.type === 'tool_result') as { result: string } 74 expect(tr.result).toBe('ECHO: hi') 75 // 3 条消息:assistant(tool_use) + user(tool_result) + assistant(text) 76 expect(ctx.messages).toHaveLength(3) 77 }) 78 79 it('未知 tool 名 → 把报错结果放回到 Context', async () => { 80 let round = 0 81 mockStreamFn.mockImplementation(async function* (): AsyncGenerator<StreamEvent> { 82 if (round === 0) { 83 yield { type: 'tool_call', id: 't1', name: 'nope', args: {} } 84 yield { type: 'done', stopReason: 'tool_use' } 85 } else { 86 yield { type: 'text_delta', delta: 'ok' } 87 yield { type: 'done', stopReason: 'end_turn' } 88 } 89 round++ 90 }) 91 92 const events: { type: string; [k: string]: unknown }[] = [] 93 for await (const e of runAgent(model, { messages: [] }, [] as AgentTool[])) events.push(e) 94 95 const tr = events.find(e => e.type === 'tool_result') as { result: string } 96 expect(tr.result).toContain('not found') 97 })
边界 / 失败模式
src/agent.ts:107abort 时丢弃 tool_calls,避免缺 tool_result 的恢复错误。src/agent.ts:125max_tokens 下不执行工具,防止半截 JSON 参数造成文件破坏。src/agent.ts:146明确没有参数验证,生产化必须补 schema validation。