LLM + Tools 深度解析
这页拆 src/llm.ts:57 的消息转换、src/llm.ts:107 的 SSE 解析和 src/tools.ts:71 的工具契约。
导入链与契约
src/llm.ts:8 定义 Model,src/llm.ts:20 定义 ContentBlock,src/llm.ts:38 定义 StreamEvent。agent 层只看到这些类型,不需要知道底层 API 的 chunk 格式。
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}
核心实现,逐段拆
▸内部 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 分别覆盖这两种转换。
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 片段验证它能拼出完整参数。
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 很实用的小护栏。
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}
关键流程
请求体在 src/llm.ts:175 生成,工具 definitions 在 src/llm.ts:178 注入,流读取在 src/llm.ts:204 开始。遇到非 2xx,src/llm.ts:198 会读响应文本并 yield error,而不是 throw 到 UI。
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 }
边界 / 失败模式
src/llm.ts:151JSON parse 失败会退回空对象,生产版应把 parse error 显式传给模型。src/tools.ts:111run_bash 只有 timeout,没有命令沙箱。src/tools.ts:87edit 依赖字符串唯一匹配,不适合复杂 AST 改写。