piscratch
§ · Nested detail page

Agent Loop 深度解析

这页只拆 src/agent.ts:80 到 src/agent.ts:173:它如何收集模型事件、执行工具、补齐结果并维持 Context 可恢复性。

§ 00 · IMPORT CHAIN

导入链与契约

src/agent.ts:10 只从 llm 层拿 stream 和 message builder,src/agent.ts:13 定义 AgentTool,src/agent.ts:21 定义 AgentEvent。这个契约让 UI 只消费事件,不碰 OpenAI 细节。

src/agent.ts · L10-30TypeScript
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
§ 01 · CORE IMPL

核心实现,逐段拆

▸压缩不是删除历史,而是替换为摘要

src/agent.ts:37 先检查 abort 和长度,src/agent.ts:41 切出旧消息,src/agent.ts:50 构造摘要 context,src/agent.ts:57 用同一个 stream 总结。失败时 src/agent.ts:63 直接保留原始 context,这是避免空摘要污染会话的关键。

src/agent.ts · L37-70TypeScript
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 可以流式显示,而协议状态仍完整。

src/agent.ts · L89-134TypeScript
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 消息进入下一轮。

src/agent.ts · L145-173TypeScript
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  }
§ 02 · KEY FLOW

关键流程

context.messages + tools ↓ src/agent.ts:91 compact stream events ↓ src/agent.ts:122 assistant message tool execution ↓ src/agent.ts:172 tool_result message next while iteration

test/agent.test.ts:48 用两轮 mock stream 验证这条路径,test/agent.test.ts:72 断言事件顺序,test/agent.test.ts:76 确认 context 最终有三条消息。未知工具路径由 test/agent.test.ts:79 覆盖。

test/agent.test.ts · L48-97TypeScript
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  })
§ 03 · EDGE CASES

边界 / 失败模式

  • src/agent.ts:107 abort 时丢弃 tool_calls,避免缺 tool_result 的恢复错误。
  • src/agent.ts:125 max_tokens 下不执行工具,防止半截 JSON 参数造成文件破坏。
  • src/agent.ts:146 明确没有参数验证,生产化必须补 schema validation。
Generated · 2026-08-13Source · SaladDay/pi-from-scratch← 返回