1024 想做一个轻一点的礼物:输入一小段表达式,得到十四行中文短诗。这个点子可以交给 AI 扩写,但“看起来像解析器”的代码很容易把人带偏。字符串里出现for,不代表程序真的有循环;数出几个关键词,也得不到抽象语法树。
我更愿意先做一个很小、能解释的核心。它只接受变量=数字加法,例如leaf=2+3+4。输出确实是一棵树,却不冒充 JavaScript 编译器。没有函数、注释、负数和括号;遇到这些输入就报错,而不是猜一个结果。
让结构可检查,再谈创意
10 月 22 日,Anthropic 宣布把 Agent Skills 移交给 AAIF。可携带的流程说明让创作工具更容易组合,但说明写得完整,不等于生成代码实现了它。对这个小玩具而言,最便宜的检查就是打印树,并数清输出的非空行。
下面先扫描词元,再按assignment := name '=' number ('+' number)*解析。加法从左向右结合,所以leaf=2+3+4的右子树是(2+3)+4。解析完成后还要确认没有残余词元,避免把leaf=2+3 garbage的前半段当成成功。
诗句只使用树里的变量名和数字个数做模板映射。这是确定性文本生成,不调用模型,不保证押韵,也不宣称符合英语商籁体的音步。中文的十四行,只是这里选择的一种排版约束。若后面接模型润色,应该把原树、模板和润色结果分别保存,让读者知道哪里发生了自由改写。
为什么宁可少支持一点
边界小,失败就容易看见。leaf=2+3应通过;漏掉等号、加入乘号、把关键词塞进字符串,都应拒绝。生产环境若要处理真实 JavaScript,需要完整语法解析器与资源限制,不该继续往这段玩具代码里堆正则。
将代码保存为sonnet.js后运行node sonnet.js。本地验证覆盖结构、非法输入和十四行数量;没有运行浏览器界面或真实模型。最后的文字仍需要人读一遍:行数是程序能保证的,诗意不是。
可独立运行的核心例子
function parse(source) { if (source.length > 200) throw Error('输入过长'); const tokens = []; const scan = /\s*([A-Za-z_][A-Za-z_0-9]*|\d+|[=+])/y; let p = 0; while (p < source.length) { if (!source.slice(p).trim()) break; scan.lastIndex = p; const m = scan.exec(source); if (!m) throw Error('不支持的位置:' + p); tokens.push(m[1]); p = scan.lastIndex; } let i = 0; const take = () => tokens[i++]; const name = take(); if (!/^[A-Za-z_]\w*$/.test(name || '') || take() !== '=') throw Error('需要变量=表达式'); function number() { const t = take(); if (!/^\d+$/.test(t || '')) throw Error('需要非负整数'); const value = Number(t); if (!Number.isSafeInteger(value)) throw Error('整数超界'); return {type:'Number', value}; } let value = number(); while (tokens[i] === '+') { take(); value = {type:'Add', left:value, right:number()}; } if (i !== tokens.length) throw Error('多余词元'); return {type:'Assign', name, value}; } function countNumbers(node) { if(node.type==='Number') return 1; if(node.type==='Add') return countNumbers(node.left)+countNumbers(node.right); if(node.type==='Assign') return countNumbers(node.value); throw Error('未知节点'); } function poem(tree) { return [ `${tree.name} 是纸上的一枚落叶`, '等号替它留下一处位置', `${countNumbers(tree)}个数字靠近,尚未惊动夜色`, '加号把两盏灯接在一起', '先读左边的枝条', '再看右边的新芽', '树没有许诺整片森林', '只解释这一个句子', '我把能验证的留下', '把不能保证的写在页边', '窗口亮着,猫没有回答', '键盘停在下一行之前', '十四行到这里合上', '剩下的空白交给读者' ]; } const tree = parse('leaf=2+3+4'); if (tree.value.left.type !== 'Add' || countNumbers(tree)!==3 || poem(tree).length !== 14) throw Error('结构测试失败'); for (const bad of ['x=2*3','x=2+','x 2','x=2 garbage']) { let rejected=false; try { parse(bad); } catch { rejected=true; } if (!rejected) throw Error('应当拒绝:'+bad); } console.log(JSON.stringify(tree)); console.log(poem(tree).join('\n'));