llm.js 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. 'use strict';
  2. /**
  3. * 智能问数智能体 · 对话模型通道
  4. * ----------------------------------------------------------------------------
  5. * OpenAI 兼容 /v1/chat/completions 手写 HTTP(零依赖纪律,与 nl-query 同款)。
  6. * chatCompletion / extractJson 自 nl-query.js 抽出共用(nl-query 改为 require 本模块)。
  7. *
  8. * parseAgentAction 是 harness 的输入闸门:模型每回合只允许输出一个 JSON 对象,
  9. * 二选一 —— 调工具 {"thought","action":{tool,args}} 或收敛 {"thought","final":{answer,…}}。
  10. * 解析失败抛带可读 reason 的错误,供主循环回注模型自修复。
  11. */
  12. const https = require('https');
  13. const http = require('http');
  14. function chatCompletion({ apiBase, apiKey, model, messages, timeoutMs = 45000, temperature = 0 }) {
  15. return chatCompletionDetailed({ apiBase, apiKey, model, messages, timeoutMs, temperature }).then((out) => out.content);
  16. }
  17. /**
  18. * 同 chatCompletion,但返回 { content, usage }(usage 来自 OpenAI 兼容响应的
  19. * usage 字段:prompt_tokens / completion_tokens;网关不给时为 null)。
  20. * 智能体主循环用它记录每回合的 token 用量(编排时间线展示)。
  21. */
  22. function chatCompletionDetailed({ apiBase, apiKey, model, messages, timeoutMs = 45000, temperature = 0 }) {
  23. return new Promise((resolve, reject) => {
  24. let base;
  25. try { base = new URL(apiBase); } catch (e) { return reject(new Error('对话模型地址不合法: ' + apiBase)); }
  26. const body = JSON.stringify({ model, messages, temperature, stream: false });
  27. const options = {
  28. hostname: base.hostname,
  29. port: base.port || 443,
  30. path: (base.pathname.replace(/\/+$/, '') || '') + '/v1/chat/completions',
  31. method: 'POST',
  32. headers: {
  33. Authorization: `Bearer ${apiKey}`,
  34. 'Content-Type': 'application/json; charset=utf-8',
  35. 'Content-Length': Buffer.byteLength(body),
  36. },
  37. timeout: timeoutMs,
  38. };
  39. const req = (base.protocol === 'http:' ? http : https).request(options, (res) => {
  40. const chunks = [];
  41. res.on('data', (c) => chunks.push(c));
  42. res.on('end', () => {
  43. const raw = Buffer.concat(chunks).toString('utf8');
  44. let json = null;
  45. try { json = JSON.parse(raw); } catch { /* 保留 raw */ }
  46. if (res.statusCode !== 200) {
  47. const msg = (json && json.error && json.error.message) || raw.slice(0, 200) || `HTTP ${res.statusCode}`;
  48. return reject(new Error(`对话模型请求失败: ${msg}`));
  49. }
  50. const content = json && json.choices && json.choices[0] && json.choices[0].message && json.choices[0].message.content;
  51. if (!content) return reject(new Error('对话模型返回为空'));
  52. const u = (json && json.usage) || null;
  53. resolve({
  54. content,
  55. usage: u ? {
  56. promptTokens: Number(u.prompt_tokens) || null,
  57. completionTokens: Number(u.completion_tokens) || null,
  58. } : null,
  59. });
  60. });
  61. });
  62. req.on('error', reject);
  63. req.on('timeout', () => req.destroy(new Error('对话模型请求超时')));
  64. req.write(body);
  65. req.end();
  66. });
  67. }
  68. /** 从模型输出中稳健地提取第一个 JSON 对象(支持 ```json 围栏与前后杂讯) */
  69. function extractJson(text) {
  70. const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
  71. const candidate = fenced ? fenced[1] : text;
  72. const start = candidate.indexOf('{');
  73. const end = candidate.lastIndexOf('}');
  74. if (start === -1 || end <= start) throw new Error('模型未返回 JSON');
  75. return JSON.parse(candidate.slice(start, end + 1));
  76. }
  77. /**
  78. * 解析一回合模型输出为结构化动作。
  79. * 返回 { type:'tool', thought, tool, args } 或 { type:'final', thought, final }。
  80. * chart / applied 只收编形状,指标白名单等业务钳制在 guard / 工具层做。
  81. */
  82. function parseAgentAction(text) {
  83. let obj;
  84. try {
  85. obj = extractJson(text);
  86. } catch (e) {
  87. throw new Error('输出不是可解析的 JSON 对象:' + (e.message || e));
  88. }
  89. if (!obj || typeof obj !== 'object' || Array.isArray(obj)) throw new Error('输出不是 JSON 对象');
  90. const thought = String(obj.thought || '').slice(0, 500);
  91. if (obj.final && typeof obj.final === 'object' && !Array.isArray(obj.final)) {
  92. const answer = String(obj.final.answer || '').trim();
  93. if (!answer) throw new Error('final.answer 不能为空');
  94. const final = { answer: answer.slice(0, 4000) };
  95. if (obj.final.chart && typeof obj.final.chart === 'object') {
  96. final.chart = {
  97. type: obj.final.chart.type,
  98. x: obj.final.chart.x,
  99. y: Array.isArray(obj.final.chart.y) ? obj.final.chart.y : [],
  100. title: obj.final.chart.title,
  101. };
  102. }
  103. return { type: 'final', thought, final };
  104. }
  105. if (obj.action && typeof obj.action === 'object' && !Array.isArray(obj.action)) {
  106. const tool = String(obj.action.tool || '').trim();
  107. if (!tool) throw new Error('action.tool 不能为空');
  108. const rawArgs = obj.action.args;
  109. const args = (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) ? rawArgs : {};
  110. return { type: 'tool', thought, tool: tool.slice(0, 64), args };
  111. }
  112. throw new Error('输出缺少 action 或 final:每回合必须二选一');
  113. }
  114. module.exports = { chatCompletion, chatCompletionDetailed, extractJson, parseAgentAction };