piscratch
§ · Nested detail page

LLM + Tools 深度解析

这页拆 src/llm.ts:57 的消息转换、src/llm.ts:107 的 SSE 解析和 src/tools.ts:71 的工具契约。

§ 00 · IMPORT CHAIN

导入链与契约

src/llm.ts:8 定义 Model,src/llm.ts:20 定义 ContentBlock,src/llm.ts:38 定义 StreamEvent。agent 层只看到这些类型,不需要知道底层 API 的 chunk 格式。

src/llm.ts · L7-49TypeScript
7/** 模型配置 */
8export type Model = {
9  apiKey: string
10  model: string          // 如 "gpt-4o" 或 "glm-5.2"
11  baseUrl?: string       // 默认 https://api.openai.com/v1
12  maxTokens?: number     // 不设则由 API 决定默认值(cli.ts 设为 4096)
13}
14
15/**
16 * content block:消息内容的结构化单元。
17 * 注意:tool_result 放在 ContentBlock 里再塞进 user message,
18 * pi 里它是独立的 ToolResultMessage 类型,nanopi 简化为统一结构。
19 */
20export type ContentBlock =
21  | { type: 'text'; text: string }
22  | { type: 'tool_use'; id: string; name: string; input: unknown }
23  | { type: 'tool_result'; tool_use_id: string; content: string }
24
25/** 消息:user / assistant 共用同一结构 */
26export type Message = {
27  role: 'user' | 'assistant'
28  content: string | ContentBlock[]
29}
30
31/** Context:纯 JSON,可 stringify 落盘 */
32export type Context = {
33  systemPrompt?: string
34  messages: Message[]
35}
36
37/** 流事件:llm 模块对外的统一输出 */
38export type StreamEvent =
39  | { type: 'text_delta'; delta: string }
40  | { type: 'tool_call'; id: string; name: string; args: unknown }
41  | { type: 'done'; stopReason: 'end_turn' | 'tool_use' | 'max_tokens' | 'aborted' }
42  | { type: 'error'; error: Error }
43
44/** agent 模块传入的 tool 定义格式 */
45export type ToolDef = {
46  name: string
47  description: string
48  parameters: object
49}
§ 01 · CORE IMPL

核心实现,逐段拆

▸内部 Context 到 OpenAI messages

src/llm.ts:61 遍历 message,src/llm.ts:68 识别 assistant,src/llm.ts:74 生成 function tool_calls,src/llm.ts:83 再把 user 中的 tool_result 拆成 role:tool。test/llm.test.ts:90 和 test/llm.test.ts:107 分别覆盖这两种转换。

src/llm.ts · L57-92TypeScript
57export function contextToOpenAIMessages(context: Context): object[] {
58  const messages: object[] = []
59  if (context.systemPrompt) messages.push({ role: 'system', content: context.systemPrompt })
60
61  for (const msg of context.messages) {
62    if (typeof msg.content === 'string') {
63      messages.push({ role: msg.role, content: msg.content })
64      continue
65    }
66
67    const blocks = msg.content
68    if (msg.role === 'assistant') {
69      const toolCalls: object[] = []
70      let text = ''
71      for (const b of blocks) {
72        if (b.type === 'text') text += b.text
73        else if (b.type === 'tool_use') {
74          toolCalls.push({ id: b.id, type: 'function', function: { name: b.name, arguments: JSON.stringify(b.input) } })
75        }
76      }
77      // OpenAI 要求 assistant 消息必须有 content(非 null)或 tool_calls。
78      // 纯 tool_call 时 content 为 null;两者皆空(如 abort/error 后的无内容轮次)用空串占位,避免 API 400。
79      const content = text || (toolCalls.length ? null : '')
80      messages.push({ role: 'assistant', content, tool_calls: toolCalls.length ? toolCalls : undefined })
81    } else {
82      // user message 里的 tool_result block → OpenAI 要求独立的 role:tool 消息
83      for (const b of blocks) {
84        if (b.type === 'tool_result') {
85          messages.push({ role: 'tool', tool_call_id: b.tool_use_id, content: b.content })
86        } else if (b.type === 'text') {
87          messages.push({ role: 'user', content: b.text })
88        }
89      }
90    }
91  }
92  return messages

▸tool_call arguments 是增量拼出来的

src/llm.ts:123 处理 delta.tool_calls,src/llm.ts:127 初始化 buffer,src/llm.ts:132 追加 arguments 片段,src/llm.ts:148 按 index 排序 flush。test/llm.test.ts:40 用两段 JSON 片段验证它能拼出完整参数。

src/llm.ts · L107-155TypeScript
107function handleSSELine(
108  data: string,
109  toolCallBuffers: Map<number, { id: string; name: string; argsBuf: string }>,
110): { textDelta: string | null; stopReason: 'end_turn' | 'tool_use' | 'max_tokens' | null } {
111  let chunk: OpenAIChunk
112  try { chunk = JSON.parse(data) as OpenAIChunk } catch { return { textDelta: null, stopReason: null } }
113
114  const choice = chunk.choices[0]
115  if (!choice) return { textDelta: null, stopReason: null }
116
117  let textDelta: string | null = null
118  let stopReason: 'end_turn' | 'tool_use' | 'max_tokens' | null = null
119
120  if (choice.delta?.content) textDelta = choice.delta.content
121
122  // tool_call 增量:按 index 累积 name + arguments 的 partial JSON
123  if (choice.delta?.tool_calls) {
124    for (const tc of choice.delta.tool_calls) {
125      const idx = tc.index ?? 0
126      if (!toolCallBuffers.has(idx)) {
127        toolCallBuffers.set(idx, { id: tc.id ?? `call_${idx}`, name: '', argsBuf: '' })
128      }
129      const entry = toolCallBuffers.get(idx)!
130      if (tc.id) entry.id = tc.id
131      if (tc.function?.name) entry.name = tc.function.name
132      if (tc.function?.arguments) entry.argsBuf += tc.function.arguments
133    }
134  }
135
136  // finish_reason 映射:tool_calls → tool_use,length → max_tokens,stop → end_turn(默认值)
137  if (choice.finish_reason === 'tool_calls') stopReason = 'tool_use'
138  else if (choice.finish_reason === 'length') stopReason = 'max_tokens'
139
140  return { textDelta, stopReason }
141}
142
143/** 流结束:把累积的 tool_calls 按顺序发出 */
144function flushToolCalls(
145  toolCallBuffers: Map<number, { id: string; name: string; argsBuf: string }>,
146): { id: string; name: string; args: unknown }[] {
147  const calls: { id: string; name: string; args: unknown }[] = []
148  for (const [, tc] of [...toolCallBuffers].sort((a, b) => a[0] - b[0])) {
149    let args: unknown = {}
150    if (tc.argsBuf) {
151      try { args = JSON.parse(tc.argsBuf) } catch { args = {} }
152    }
153    calls.push({ id: tc.id, name: tc.name, args })
154  }
155  return calls

▸工具层只暴露 execute

src/tools.ts:34 到 src/tools.ts:49 是 read_file,src/tools.ts:52 到 src/tools.ts:69 是 write_file;它们没有 agent 状态。src/tools.ts:24 的 truncateOutput 把长输出存临时文件并保留末尾,是终端 agent 很实用的小护栏。

src/tools.ts · L20-69TypeScript
20/**
21 * 截取工具输出:超过 maxLines 行时只保留尾部,完整输出存到临时文件。
22 * 尾部优先——错误信息通常在末尾。
23 */
24async function truncateOutput(content: string, maxLines = MAX_OUTPUT_LINES): Promise<string> {
25  const lines = content.split('\n')
26  if (lines.length <= maxLines) return content
27  const kept = lines.slice(-maxLines).join('\n')
28  const tmpPath = path.join(os.tmpdir(), `nanopi-output-${process.pid}-${truncateCounter++}.txt`)
29  await fs.writeFile(tmpPath, content, 'utf-8')
30  return `[output truncated: showing last ${maxLines} of ${lines.length} lines. full output: ${tmpPath}]\n${kept}`
31}
32
33/** read_file:返回文件内容(截取尾部防止超大输出) */
34const readFile: AgentTool = {
35  name: 'read_file',
36  description: '读取文件内容。参数:path(文件路径)。大文件截取最后 200 行。',
37  parameters: {
38    type: 'object',
39    properties: {
40      path: { type: 'string', description: '要读取的文件路径' },
41    },
42    required: ['path'],
43  },
44  execute: async (args) => {
45    const { path: filePath } = args as { path: string }
46    const content = await fs.readFile(filePath, 'utf-8')
47    return await truncateOutput(content)
48  },
49}
50
51/** write_file:覆盖写入文件 */
52const writeFile: AgentTool = {
53  name: 'write_file',
54  description: '写入文件(覆盖)。参数:path(路径)、content(内容)',
55  parameters: {
56    type: 'object',
57    properties: {
58      path: { type: 'string', description: '要写入的文件路径' },
59      content: { type: 'string', description: '文件内容' },
60    },
61    required: ['path', 'content'],
62  },
63  execute: async (args) => {
64    const { path: filePath, content } = args as { path: string; content: string }
65    await fs.mkdir(path.dirname(filePath) || '.', { recursive: true })
66    await fs.writeFile(filePath, content, 'utf-8')
67    return `wrote ${filePath} (${content.length} chars)`
68  },
69}
§ 02 · KEY FLOW

关键流程

Context blocks ↓ src/llm.ts:57 OpenAI messages + tools ↓ src/llm.ts:187 fetch SSE chunks ↓ src/llm.ts:235 StreamEvent tool_call/text_delta

请求体在 src/llm.ts:175 生成,工具 definitions 在 src/llm.ts:178 注入,流读取在 src/llm.ts:204 开始。遇到非 2xx,src/llm.ts:198 会读响应文本并 yield error,而不是 throw 到 UI。

src/llm.ts · L167-238TypeScript
167export async function* stream(
168  model: Model,
169  context: Context,
170  opts: { tools?: ToolDef[]; signal?: AbortSignal } = {},
171): AsyncGenerator<StreamEvent> {
172  const url = `${model.baseUrl ?? 'https://api.openai.com/v1'}/chat/completions`
173  const messages = contextToOpenAIMessages(context)
174
175  const body: Record<string, unknown> = { model: model.model, stream: true, messages }
176  if (model.maxTokens) body.max_tokens = model.maxTokens
177  if (opts.tools?.length) {
178    body.tools = opts.tools.map(t => ({
179      type: 'function',
180      function: { name: t.name, description: t.description, parameters: t.parameters },
181    }))
182  }
183
184  // 发请求
185  let response: Response
186  try {
187    response = await fetch(url, {
188      method: 'POST',
189      headers: { 'content-type': 'application/json', authorization: `Bearer ${model.apiKey}` },
190      body: JSON.stringify(body),
191      signal: opts.signal,
192    })
193  } catch (e) {
194    if (opts.signal?.aborted) { yield { type: 'done', stopReason: 'aborted' }; return }
195    yield { type: 'error', error: e as Error }; return
196  }
197
198  if (!response.ok || !response.body) {
199    const text = await response.text().catch(() => 'unknown error')
200    yield { type: 'error', error: new Error(`API ${response.status}: ${text}`) }; return
201  }
202
203  // 逐行解析 SSE
204  const reader = response.body.getReader()
205  const decoder = new TextDecoder()
206  let buf = ''
207  let stopReason: 'end_turn' | 'tool_use' | 'max_tokens' = 'end_turn'
208  const toolCallBuffers = new Map<number, { id: string; name: string; argsBuf: string }>()
209
210  try {
211    while (true) {
212      const { done, value } = await reader.read()
213      if (done) break
214      buf += decoder.decode(value, { stream: true })
215
216      let nl: number
217      while ((nl = buf.indexOf('\n')) >= 0) {
218        const line = buf.slice(0, nl).trim()
219        buf = buf.slice(nl + 1)
220        if (!line.startsWith('data: ')) continue
221        const data = line.slice(6)
222        if (data === '[DONE]') continue
223
224        const result = handleSSELine(data, toolCallBuffers)
225        if (result.textDelta) yield { type: 'text_delta', delta: result.textDelta }
226        if (result.stopReason) stopReason = result.stopReason
227      }
228    }
229  } catch (e) {
230    if (opts.signal?.aborted) { yield { type: 'done', stopReason: 'aborted' }; return }
231    yield { type: 'error', error: e as Error }; return
232  }
233
234  // 发出累积的 tool_calls
235  for (const tc of flushToolCalls(toolCallBuffers)) {
236    yield { type: 'tool_call', id: tc.id, name: tc.name, args: tc.args }
237  }
238  yield { type: 'done', stopReason: opts.signal?.aborted ? 'aborted' : stopReason }
§ 03 · EDGE CASES

边界 / 失败模式

  • src/llm.ts:151 JSON parse 失败会退回空对象,生产版应把 parse error 显式传给模型。
  • src/tools.ts:111 run_bash 只有 timeout,没有命令沙箱。
  • src/tools.ts:87 edit 依赖字符串唯一匹配,不适合复杂 AST 改写。
Generated · 2026-08-13Source · SaladDay/pi-from-scratch← 返回