让模型调用工具,需要把建议变成可核验的执行记录。本文从一次库存查询建立最小循环,再加入结果关联、停止状态、依赖与未知写结果。读完应能指出某个动作只是被提出、已经执行,还是已经通过任务验收。
从固定流程到反馈决策
固定工作流由程序预先安排主要步骤,例如读取订单、抽取字段、校验、入库;模型可以参与某一步,但主要路径由代码控制。Agent 则让模型根据观察动态决定下一步,例如测试失败后自行定位相关文件,再选择新的修复动作。两者可以组合:一个确定性外层流程也能包含受限的 Agent 子任务。Building effective agents
ReAct 研究将推理与行动交替组织,让模型依据环境反馈更新后续处理。ReAct 这不是按“有没有聊天界面”划分产品:聊天系统也可以调用工具,Agent 也可能一次回答就完成简单请求。
模型推理产生表示和输出,外部动作由某个执行环境落实。客户端工具由应用执行;服务端工具可能由供应商执行。因此权限和审计边界分布在实际执行组件中,不能假定所有操作都在本地宿主,也不能把模型生成的命令文本视为已经执行。
完成声明与完成证据
完成是一项待验证的声明。这个模型允许在没有执行、没有观察时直接宣称完成,验收层仍独立检查证据。观察也有时间边界:修改前的读取不能证明修改后的状态。
正在呈现知识画面
完成声明与完成证据执行成功、读到结果、声明完成是三件事;验收必须检查实际证据。
{"id":"ai-loop-evidence","title":"完成声明与完成证据","summary":"执行成功、读到结果、声明完成是三件事;验收必须检查实际证据。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"actions\"><button id=\"execute\" data-i18n=\"execute\"></button><button id=\"observe\" data-i18n=\"observe\"></button><button id=\"claim\" data-i18n=\"claim\"></button><button id=\"reset\" data-i18n=\"reset\"></button></div><section class=\"panel\"><h3 data-i18n=\"state\"></h3><div id=\"state\"></div></section><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"explain\"></p></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control output{color:var(--accent);text-align:right;font-variant-numeric:tabular-nums;min-width:4em}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1;min-width:0;overflow-wrap:anywhere;font-size:13px;min-height:44px}.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:16px 0}.actions button{min-width:0;min-height:44px;font-size:13px;overflow-wrap:anywhere}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0;overflow-wrap:anywhere}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin:18px 0}.metrics>div{border-left:2px solid var(--accent);padding:0 6px 0 10px;min-width:0}.metrics span{display:block;min-height:3.6em;color:var(--muted);font-size:12px;overflow-wrap:anywhere}.metrics strong{font-size:21px;display:block;min-height:2em;font-weight:550;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.check{display:flex;align-items:center;gap:9px;font-size:13px;margin:14px 0}.check input{width:18px;height:18px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:14px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:44px}.panel{padding:14px;border:1px solid var(--rule);border-radius:9px;margin:16px 0;min-width:0}.panel>header{font-size:13px;font-weight:600;margin-bottom:12px;color:var(--muted)}.panel>div{overflow-wrap:anywhere}.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:8px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:56px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:6px 3px;font-size:12px;overflow-wrap:anywhere}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.table{display:grid;gap:5px;font-size:12px}.tr{display:grid;gap:5px}.tr>div{background:var(--surface);border-radius:4px;padding:9px 6px;min-width:0;min-height:5em;overflow-wrap:anywhere;display:flex;align-items:center}.tr>.gold{background:var(--second-soft)}.tr>.on{background:var(--accent-soft)}.formula,.source{font:13px/1.8 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere;margin:16px 0}.formula{border-top:1px solid var(--rule);padding-top:12px;min-height:7.2em}.source{min-height:8em;background:var(--surface);padding:12px;border-radius:8px}.intervals{margin:16px 0}.interval{position:relative;height:38px;background:var(--surface);margin:6px 0;border-radius:4px;overflow:hidden}.interval i{position:absolute;height:100%;background:var(--accent-soft);border-left:2px solid var(--accent)}.interval i.gold{background:var(--second-soft);border-color:var(--second)}.interval span{position:relative;z-index:1;font:12px/38px ui-monospace,monospace;padding-left:7px}.bars{display:grid;gap:10px;margin:16px 0}.bar-row{display:grid;grid-template-columns:44px minmax(0,1fr) 62px;gap:8px;align-items:center;font:12px ui-monospace,monospace}.bar-track{height:24px;background:var(--surface);border-radius:4px;overflow:hidden}.bar-track i{display:block;height:100%;background:var(--accent);transition:width .2s}.bar-track i.gold{background:var(--second)}.bar-track i.empty{opacity:.2}.bar-row output{text-align:right}.diagram{display:block;width:100%;height:auto;margin:18px 0}.diagram text{font:13px ui-monospace,monospace;fill:var(--ink)}.node{fill:var(--surface);stroke:var(--rule)}.node.on{fill:var(--accent-soft);stroke:var(--accent)}.node.gold{fill:var(--second-soft);stroke:var(--second)}.edge{stroke:var(--rule);stroke-width:2;fill:none}.edge.on{stroke:var(--accent)}.matrix{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;margin:16px 0}.matrix button{min-width:0;min-height:44px;font:13px ui-monospace,monospace}.matrix button[aria-pressed=true]{background:var(--accent-soft);border-color:var(--accent)}.matrix button.masked{opacity:.35}.legend{font-size:12px;color:var(--muted);min-height:3.6em;overflow-wrap:anywhere}.numeric{font-variant-numeric:tabular-nums}.scope p{line-break:strict}@media(max-width:480px){.presets button,.actions button{font-size:12px}.panel{padding:12px}.metrics{gap:10px}.metrics strong{font-size:19px}.bar-row{grid-template-columns:34px minmax(0,1fr) 58px;gap:6px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n\n.panel>h3{font-size:13px;font-weight:600;margin:0 0 12px;color:var(--muted)}\n\n.bar-row{grid-template-columns:44px minmax(0,1fr) 82px}.bar-row output{white-space:nowrap}@media(max-width:480px){.bar-row{grid-template-columns:34px minmax(0,1fr) 76px}}\n\n","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}const esc=s=>String(s).replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));const fmt=s=>'{'+[...s].sort().join(', ')+'}';function graph(id,nodes,edges){const el=$('#'+id);el.setAttribute('viewBox','0 0 360 200');el.innerHTML='<defs><marker id=\"arrow-'+id+'\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"5\" markerHeight=\"5\" orient=\"auto-start-reverse\"><path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"var(--muted)\"/></marker></defs>'+edges.map(e=>{const a=nodes[e[0]],b=nodes[e[1]],dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1;if(edges.some(r=>r[0]===e[1]&&r[1]===e[0])){const nx=-dy/d,ny=dx/d;return '<path class=\"edge '+(e[2]?'on':'')+'\" d=\"M '+(a.x+dx/d*22+nx*12)+' '+(a.y+dy/d*22+ny*12)+' Q '+((a.x+b.x)/2+nx*38)+' '+((a.y+b.y)/2+ny*38)+' '+(b.x-dx/d*25+nx*12)+' '+(b.y-dy/d*25+ny*12)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}return '<line class=\"edge '+(e[2]?'on':'')+'\" x1=\"'+(a.x+dx/d*25)+'\" y1=\"'+(a.y+dy/d*25)+'\" x2=\"'+(b.x-dx/d*28)+'\" y2=\"'+(b.y-dy/d*28)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}).join('')+nodes.map(n=>'<g><circle class=\"node '+(n.cls||'')+'\" cx=\"'+n.x+'\" cy=\"'+n.y+'\" r=\"25\"/><text x=\"'+n.x+'\" y=\"'+(n.y+4)+'\" text-anchor=\"middle\">'+esc(n.name)+'</text>'+(n.sub?'<text x=\"'+n.x+'\" y=\"'+(n.y+43)+'\" text-anchor=\"middle\">'+esc(n.sub)+'</text>':'')+'</g>').join('');}function table(id,rows){$('#'+id).classList.add('table');$('#'+id).replaceChildren(...rows.map(r=>{const row=document.createElement('div');row.className='tr';row.style.gridTemplateColumns='repeat('+r.length+',minmax(0,1fr))';r.forEach(x=>{const c=document.createElement('div');if(typeof x==='object'){c.textContent=x.text;c.className=x.cls||'';}else c.textContent=x;row.append(c)});return row;}));}function band(id,parts,total){$('#'+id).replaceChildren(...parts.map((p,i)=>{const e=document.createElement('i');e.style.width=(100*p.value/total)+'%';e.className=i%2?'gold':'on';e.title=p.name+': '+p.value;return e}));}function intervals(id,items,total){$('#'+id).replaceChildren(...items.map(p=>{const row=document.createElement('div');row.className='interval';const bar=document.createElement('i');bar.style.left=(100*p.start/total)+'%';bar.style.width=(100*(p.end-p.start)/total)+'%';bar.className=p.cls||'';const label=document.createElement('span');label.textContent=p.label;row.append(bar,label);return row;}));}function plot(id,series,xr,yr,opts={}){const e=$('#'+id),X=x=>42+(x-xr[0])/(xr[1]-xr[0])*298,Y=y=>175-(y-yr[0])/(yr[1]-yr[0])*148;let out='<defs><clipPath id=\"clip-'+id+'\"><rect x=\"42\" y=\"27\" width=\"298\" height=\"148\"/></clipPath></defs>';for(let i=0;i<3;i++){let x=xr[0]+i*(xr[1]-xr[0])/2,y=yr[0]+i*(yr[1]-yr[0])/2;out+='<path class=\"gridline\" d=\"M '+X(x)+' 27V175M42 '+Y(y)+'H340\"/><text x=\"'+X(x)+'\" y=\"194\" text-anchor=\"middle\">'+esc(opts.xfmt?opts.xfmt(x):Number(x.toFixed(2)))+'</text><text x=\"36\" y=\"'+(Y(y)+4)+'\" text-anchor=\"end\">'+esc(opts.yfmt?opts.yfmt(y):Number(y.toFixed(2)))+'</text>';}out+='<g clip-path=\"url(#clip-'+id+')\">';for(const s of series){out+='<path fill=\"none\" stroke=\"'+(s.color||'var(--accent)')+'\" stroke-width=\"2.5\" '+(s.dash?'stroke-dasharray=\"5 4\"':'')+' d=\"'+s.pts.map((p,i)=>(i?'L':'M')+X(p[0]).toFixed(2)+','+Y(p[1]).toFixed(2)).join(' ')+'\"/>';}for(const p of opts.points||[])out+='<circle cx=\"'+X(p[0])+'\" cy=\"'+Y(p[1])+'\" r=\"4\" fill=\"var(--second)\" stroke=\"var(--paper)\" stroke-width=\"1.5\"/>';if(opts.cursor!==undefined)out+='<path d=\"M'+X(opts.cursor)+' 27V175\" stroke=\"var(--muted)\" stroke-dasharray=\"3 3\"/>';e.innerHTML=out+'</g>';e.dataset.curves=JSON.stringify(series.map(s=>s.pts));}const curve=(fn,lo,hi,n=120)=>Array.from({length:n+1},(_,i)=>{const x=lo+(hi-lo)*i/n;return[x,fn(x)]});function inputs(draw){document.querySelectorAll('input,select').forEach(e=>{e.addEventListener('input',draw);e.addEventListener('change',draw)});draw()}const n=id=>+$('#'+id).value;const show=(id,v,d=3)=>set(id,Number(v.toFixed(d)));function bars(id,values,max=1){$('#'+id).className='bars';$('#'+id).innerHTML=values.map(v=>'<div class=\"bar-row\"><span>'+esc(v.label)+'</span><div class=\"bar-track\"><i class=\"'+(v.cls||'')+'\" style=\"width:'+Math.max(0,Math.min(100,v.value/max*100))+'%\"></i></div><output>'+esc(v.text??(v.value*100).toFixed(1)+'%')+'</output></div>').join('')}function softmax(z,T=1){let max=Math.max(...z),w=z.map(x=>Math.exp((x-max)/T)),sum=w.reduce((a,b)=>a+b,0);return w.map(x=>x/sum)}let actual=1,seen=0,claimed=false;function draw(){table('state',[[t('actual'),'v'+actual],[t('seen'),seen?'v'+seen:'—'],[t('claimed'),claimed?'✓':'—'],[t('accepted'),claimed&&seen===2?'✓':'×']]);set('explanation',t('explain'));document.body.dataset.accepted=claimed&&seen===2;}$('#execute').onclick=()=>{actual=2;draw()};$('#observe').onclick=()=>{seen=actual;draw()};$('#claim').onclick=()=>{claimed=true;draw()};$('#reset').onclick=()=>{actual=1;seen=0;claimed=false;draw()};draw();","audio":false,"strings":{"accepted":"证据验收","actual":"真实版本","claim":"声明完成","claimed":"完成声明","execute":"执行修改","explain":"先声明完成,验收仍失败。先读 v1 再执行修改,旧观察也不会自动变成 v2;必须取得与目标一致的结果证据。","heading":"完成声明与完成证据","intro":"直接操作执行、观察和声明,比较系统知道什么。","observe":"读取结果","reset":"重置","scope":"本地任务仅把文件版本从 v1 改成 v2,观察读取当前版本;不访问外部工具。","scopeLabel":"模型范围与约定","seen":"已观察版本","state":"事实、观察与验收"}}
一次工具调用的完整路径
工具定义同时是模型接口和执行契约
名称帮助模型识别动作,描述解释用途与限制,schema 定义参数结构。以只读库存查询为例,描述应说明查的是可售数量、商品使用哪个标识,以及结果的时间或版本含义;“获取库存”四个字无法表达这些边界。
{
"name": "lookup_stock",
"description": "按商品 SKU 查询当前可售数量;只读,不预留库存。",
"input_schema": {
"type": "object",
"properties": {"sku": {"type": "string"}},
"required": ["sku"],
"additionalProperties": false
}
}
这是 Claude 风格的工具定义;其他协议的字段名可能不同。schema 约束形状,不自动验证身份、商品是否属于当前租户或操作者能否查看。结构化输出也不能代替执行端验证:执行端必须只分派已注册工具,并重新核验输入与权限。Tool use with Claude
一次查询可以按下面的记录追踪:
| 阶段 | 示例记录 | 必须保留的含义 |
| 模型提出动作 | 调用 ID c17,lookup_stock,sku=A1 | 这是一项请求,尚未执行 |
| 执行前核验 | 工具存在、参数有效、主体有读权限 | 允许执行的范围 |
| 工具实际返回 | available=3,库存版本 v83 | 这是哪次读取的结果 |
| 回传观察 | 调用 ID c17 对应上述结果 | 防止结果错配 |
| 模型继续决策 | 回答可售数量或提出下一步 | 查询不等于已预留库存 |
执行结果最好包含明确状态、必要数据与来源定位。错误也应区分无此商品、参数无效、权限不足、限流或结果未知;笼统返回“失败,重试”会诱导无意义循环。
消息协议必须完整
Claude 客户端工具通过 tool_use 与匹配的 tool_result 关联;同一响应可包含多个调用。应用应保留完整 assistant 响应,并按协议组织所有相应结果。thinking、不透明状态和非文本内容不能被“只保存 text”的简化逻辑丢掉。
错误格式反馈可能影响之后的模型行为,但不是在请求中“悄悄训练了模型权重”。协议要求与推测性的行为解释应分开,具体工具消息排列规则以 工具接口文档 为准。
响应停止与任务状态
模型没有再请求工具,只说明这次模型响应已停止。它可能正确完成了任务,也可能遗漏验证、需要用户补充信息、被截断,或错误地宣称成功。应用需要独立记录任务状态,例如 running、waiting、succeeded、failed、budget_exhausted,并用任务验收条件决定何时 succeeded。
下表是 Claude 接口中的常见分支,完整枚举和后续请求格式见 Stop reasons and fallback。
| 停止原因 | 意义 | 处理方向 |
tool_use | 请求客户端工具 | 完成核验、执行与结果回传 |
end_turn | 模型结束本轮 | 检查任务验收条件后收尾 |
max_tokens | 输出达到限制 | 检查完整性;不要执行截断参数 |
pause_turn | 服务端长工具流程暂停 | 按该接口要求续接,应用仍负责循环限额 |
refusal | 模型拒绝当前生成 | 记录原因,按业务策略终止或提供替代 |
流式传输不会解除 max_tokens。如果工具参数 JSON 只收到一半就断流,不能猜测剩余字段后执行;如果服务端工具仍在运行,也不能把 pause 当作客户端应再次执行同一副作用的信号。
以“修复导入重复提交”为例,验收条件可能是补丁存在、普通测试通过、重启恢复测试通过。模型输出“已修复”但只跑了普通测试时,任务仍是未完成。验收标准应在开始时明确,并关联到实际产物版本。
依赖、重放与副作用
并行资格来自依赖关系
同时读取两份独立文档通常可以并行;“查询库存后按结果预留”有数据依赖,不能先猜参数并行发出。即便两个动作输入独立,若同时修改同一个文件或同一资源,也可能发生冲突。
执行器需要同时考虑数据依赖、共享资源、服务并发上限与权限。并行节约的是可重叠的等待时间,代价包括峰值负载和结果合并;不能因为模型同一轮提出多个调用就无条件全部并发。
调用 ID 不等于业务幂等键
调用 ID 用来把响应关联回提议。模型重试时可能产生新的调用 ID,但表达同一个业务动作;这时仅按调用 ID 去重,仍可能重复创建订单或发送通知。业务操作键应由应用按明确事务语义产生、保存,并由支持幂等的服务核验同一键与同一请求是否一致。
| 失败状态 | 是否可直接重试 | 合理处理 |
| 只读查询超时 | 通常可以,但数据可能变化 | 有限重试并保留读取时点 |
| 参数校验失败 | 原样重试无意义 | 返回可修正的具体字段错误 |
| 无权限 | 不通过改措辞绕过 | 在授权范围内调整或报告阻塞 |
| 写操作超时、结果未知 | 不能假定没执行 | 按操作键查询结果或使用服务幂等协议 |
| 已完成动作,回传丢失 | 重做可能重复副作用 | 重放已保存结果并核对业务状态 |
取消同样不是回滚:停止模型生成或取消本地等待,不保证远端已经开始的动作被撤销。执行器应把“确认未执行”“已完成”“结果未知”区分开,恢复时先消除未知状态。
超时以后,重试会写几次?
把网络结果与业务结果分开:请求没有返回,不等于没有写入。这里服务端事实始终可见,调用方却只有未知状态;查询或同键重试才取得确认。去重必须由服务端保证,模型记住“我已调用”不能提供同等保证。
正在呈现知识画面
超时以后,重试会写几次?调用 ID 标识尝试,操作键标识业务操作;服务端去重才能使相同操作键不重复产生副作用。
{"id":"ai-unknown-write","title":"超时以后,重试会写几次?","summary":"调用 ID 标识尝试,操作键标识业务操作;服务端去重才能使相同操作键不重复产生副作用。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"actions\"><button id=\"send\" data-i18n=\"send\"></button><button id=\"same\" data-i18n=\"same\"></button><button id=\"fresh\" data-i18n=\"fresh\"></button><button id=\"query\" data-i18n=\"query\"></button><button id=\"cancel\" data-i18n=\"cancel\"></button><button id=\"reset\" data-i18n=\"reset\"></button></div><div class=\"metrics\"><div><span data-i18n=\"effects\"></span><strong id=\"effects\"></strong></div><div><span data-i18n=\"known\"></span><strong id=\"known\"></strong></div></div><section class=\"panel\"><h3 data-i18n=\"history\"></h3><div id=\"history\"></div></section><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"explain\"></p></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control output{color:var(--accent);text-align:right;font-variant-numeric:tabular-nums;min-width:4em}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1;min-width:0;overflow-wrap:anywhere;font-size:13px;min-height:44px}.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:16px 0}.actions button{min-width:0;min-height:44px;font-size:13px;overflow-wrap:anywhere}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0;overflow-wrap:anywhere}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin:18px 0}.metrics>div{border-left:2px solid var(--accent);padding:0 6px 0 10px;min-width:0}.metrics span{display:block;min-height:3.6em;color:var(--muted);font-size:12px;overflow-wrap:anywhere}.metrics strong{font-size:21px;display:block;min-height:2em;font-weight:550;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.check{display:flex;align-items:center;gap:9px;font-size:13px;margin:14px 0}.check input{width:18px;height:18px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:14px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:44px}.panel{padding:14px;border:1px solid var(--rule);border-radius:9px;margin:16px 0;min-width:0}.panel>header{font-size:13px;font-weight:600;margin-bottom:12px;color:var(--muted)}.panel>div{overflow-wrap:anywhere}.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:8px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:56px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:6px 3px;font-size:12px;overflow-wrap:anywhere}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.table{display:grid;gap:5px;font-size:12px}.tr{display:grid;gap:5px}.tr>div{background:var(--surface);border-radius:4px;padding:9px 6px;min-width:0;min-height:5em;overflow-wrap:anywhere;display:flex;align-items:center}.tr>.gold{background:var(--second-soft)}.tr>.on{background:var(--accent-soft)}.formula,.source{font:13px/1.8 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere;margin:16px 0}.formula{border-top:1px solid var(--rule);padding-top:12px;min-height:7.2em}.source{min-height:8em;background:var(--surface);padding:12px;border-radius:8px}.intervals{margin:16px 0}.interval{position:relative;height:38px;background:var(--surface);margin:6px 0;border-radius:4px;overflow:hidden}.interval i{position:absolute;height:100%;background:var(--accent-soft);border-left:2px solid var(--accent)}.interval i.gold{background:var(--second-soft);border-color:var(--second)}.interval span{position:relative;z-index:1;font:12px/38px ui-monospace,monospace;padding-left:7px}.bars{display:grid;gap:10px;margin:16px 0}.bar-row{display:grid;grid-template-columns:44px minmax(0,1fr) 62px;gap:8px;align-items:center;font:12px ui-monospace,monospace}.bar-track{height:24px;background:var(--surface);border-radius:4px;overflow:hidden}.bar-track i{display:block;height:100%;background:var(--accent);transition:width .2s}.bar-track i.gold{background:var(--second)}.bar-track i.empty{opacity:.2}.bar-row output{text-align:right}.diagram{display:block;width:100%;height:auto;margin:18px 0}.diagram text{font:13px ui-monospace,monospace;fill:var(--ink)}.node{fill:var(--surface);stroke:var(--rule)}.node.on{fill:var(--accent-soft);stroke:var(--accent)}.node.gold{fill:var(--second-soft);stroke:var(--second)}.edge{stroke:var(--rule);stroke-width:2;fill:none}.edge.on{stroke:var(--accent)}.matrix{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;margin:16px 0}.matrix button{min-width:0;min-height:44px;font:13px ui-monospace,monospace}.matrix button[aria-pressed=true]{background:var(--accent-soft);border-color:var(--accent)}.matrix button.masked{opacity:.35}.legend{font-size:12px;color:var(--muted);min-height:3.6em;overflow-wrap:anywhere}.numeric{font-variant-numeric:tabular-nums}.scope p{line-break:strict}@media(max-width:480px){.presets button,.actions button{font-size:12px}.panel{padding:12px}.metrics{gap:10px}.metrics strong{font-size:19px}.bar-row{grid-template-columns:34px minmax(0,1fr) 58px;gap:6px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n\n.panel>h3{font-size:13px;font-weight:600;margin:0 0 12px;color:var(--muted)}\n\n.bar-row{grid-template-columns:44px minmax(0,1fr) 82px}.bar-row output{white-space:nowrap}@media(max-width:480px){.bar-row{grid-template-columns:34px minmax(0,1fr) 76px}}\n\n.metrics strong{font-size:16px;min-height:4em}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}const esc=s=>String(s).replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));const fmt=s=>'{'+[...s].sort().join(', ')+'}';function graph(id,nodes,edges){const el=$('#'+id);el.setAttribute('viewBox','0 0 360 200');el.innerHTML='<defs><marker id=\"arrow-'+id+'\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"5\" markerHeight=\"5\" orient=\"auto-start-reverse\"><path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"var(--muted)\"/></marker></defs>'+edges.map(e=>{const a=nodes[e[0]],b=nodes[e[1]],dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1;if(edges.some(r=>r[0]===e[1]&&r[1]===e[0])){const nx=-dy/d,ny=dx/d;return '<path class=\"edge '+(e[2]?'on':'')+'\" d=\"M '+(a.x+dx/d*22+nx*12)+' '+(a.y+dy/d*22+ny*12)+' Q '+((a.x+b.x)/2+nx*38)+' '+((a.y+b.y)/2+ny*38)+' '+(b.x-dx/d*25+nx*12)+' '+(b.y-dy/d*25+ny*12)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}return '<line class=\"edge '+(e[2]?'on':'')+'\" x1=\"'+(a.x+dx/d*25)+'\" y1=\"'+(a.y+dy/d*25)+'\" x2=\"'+(b.x-dx/d*28)+'\" y2=\"'+(b.y-dy/d*28)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}).join('')+nodes.map(n=>'<g><circle class=\"node '+(n.cls||'')+'\" cx=\"'+n.x+'\" cy=\"'+n.y+'\" r=\"25\"/><text x=\"'+n.x+'\" y=\"'+(n.y+4)+'\" text-anchor=\"middle\">'+esc(n.name)+'</text>'+(n.sub?'<text x=\"'+n.x+'\" y=\"'+(n.y+43)+'\" text-anchor=\"middle\">'+esc(n.sub)+'</text>':'')+'</g>').join('');}function table(id,rows){$('#'+id).classList.add('table');$('#'+id).replaceChildren(...rows.map(r=>{const row=document.createElement('div');row.className='tr';row.style.gridTemplateColumns='repeat('+r.length+',minmax(0,1fr))';r.forEach(x=>{const c=document.createElement('div');if(typeof x==='object'){c.textContent=x.text;c.className=x.cls||'';}else c.textContent=x;row.append(c)});return row;}));}function band(id,parts,total){$('#'+id).replaceChildren(...parts.map((p,i)=>{const e=document.createElement('i');e.style.width=(100*p.value/total)+'%';e.className=i%2?'gold':'on';e.title=p.name+': '+p.value;return e}));}function intervals(id,items,total){$('#'+id).replaceChildren(...items.map(p=>{const row=document.createElement('div');row.className='interval';const bar=document.createElement('i');bar.style.left=(100*p.start/total)+'%';bar.style.width=(100*(p.end-p.start)/total)+'%';bar.className=p.cls||'';const label=document.createElement('span');label.textContent=p.label;row.append(bar,label);return row;}));}function plot(id,series,xr,yr,opts={}){const e=$('#'+id),X=x=>42+(x-xr[0])/(xr[1]-xr[0])*298,Y=y=>175-(y-yr[0])/(yr[1]-yr[0])*148;let out='<defs><clipPath id=\"clip-'+id+'\"><rect x=\"42\" y=\"27\" width=\"298\" height=\"148\"/></clipPath></defs>';for(let i=0;i<3;i++){let x=xr[0]+i*(xr[1]-xr[0])/2,y=yr[0]+i*(yr[1]-yr[0])/2;out+='<path class=\"gridline\" d=\"M '+X(x)+' 27V175M42 '+Y(y)+'H340\"/><text x=\"'+X(x)+'\" y=\"194\" text-anchor=\"middle\">'+esc(opts.xfmt?opts.xfmt(x):Number(x.toFixed(2)))+'</text><text x=\"36\" y=\"'+(Y(y)+4)+'\" text-anchor=\"end\">'+esc(opts.yfmt?opts.yfmt(y):Number(y.toFixed(2)))+'</text>';}out+='<g clip-path=\"url(#clip-'+id+')\">';for(const s of series){out+='<path fill=\"none\" stroke=\"'+(s.color||'var(--accent)')+'\" stroke-width=\"2.5\" '+(s.dash?'stroke-dasharray=\"5 4\"':'')+' d=\"'+s.pts.map((p,i)=>(i?'L':'M')+X(p[0]).toFixed(2)+','+Y(p[1]).toFixed(2)).join(' ')+'\"/>';}for(const p of opts.points||[])out+='<circle cx=\"'+X(p[0])+'\" cy=\"'+Y(p[1])+'\" r=\"4\" fill=\"var(--second)\" stroke=\"var(--paper)\" stroke-width=\"1.5\"/>';if(opts.cursor!==undefined)out+='<path d=\"M'+X(opts.cursor)+' 27V175\" stroke=\"var(--muted)\" stroke-dasharray=\"3 3\"/>';e.innerHTML=out+'</g>';e.dataset.curves=JSON.stringify(series.map(s=>s.pts));}const curve=(fn,lo,hi,n=120)=>Array.from({length:n+1},(_,i)=>{const x=lo+(hi-lo)*i/n;return[x,fn(x)]});function inputs(draw){document.querySelectorAll('input,select').forEach(e=>{e.addEventListener('input',draw);e.addEventListener('change',draw)});draw()}const n=id=>+$('#'+id).value;const show=(id,v,d=3)=>set(id,Number(v.toFixed(d)));function bars(id,values,max=1){$('#'+id).className='bars';$('#'+id).innerHTML=values.map(v=>'<div class=\"bar-row\"><span>'+esc(v.label)+'</span><div class=\"bar-track\"><i class=\"'+(v.cls||'')+'\" style=\"width:'+Math.max(0,Math.min(100,v.value/max*100))+'%\"></i></div><output>'+esc(v.text??(v.value*100).toFixed(1)+'%')+'</output></div>').join('')}function softmax(z,T=1){let max=Math.max(...z),w=z.map(x=>Math.exp((x-max)/T)),sum=w.reduce((a,b)=>a+b,0);return w.map(x=>x/sum)}let keys=new Set,history=[],known='none';function draw(){set('effects',keys.size);set('known',t(known));table('history',Array.from({length:4},(_,i)=>history[i]||['—','—','—']));$('#send').disabled=history.length>0;for(const id of['same','fresh'])$('#'+id).disabled=!history.length||history.length>=4;for(const id of['query','cancel'])$('#'+id).disabled=!history.length;set('explanation',t('explain'));document.body.dataset.effects=keys.size;document.body.dataset.known=known;}function send(key,lost=false){let duplicate=keys.has(key);keys.add(key);history.push(['call-'+(history.length+1),key,lost?'?':duplicate?'↩':'✓']);if(key==='op-A')known=lost?'unknown':'done';draw()}$('#send').onclick=()=>send('op-A',true);$('#same').onclick=()=>send('op-A');$('#fresh').onclick=()=>send('op-'+(history.length+1));$('#query').onclick=()=>{known=keys.has('op-A')?'done':'none';draw()};$('#cancel').onclick=draw;$('#reset').onclick=()=>{keys.clear();history=[];known='none';draw()};draw();","audio":false,"strings":{"cancel":"本地取消等待","done":"已确认提交","effects":"服务端写入次数","explain":"首次超时发生时服务端已经提交。沿用操作键能命中去重记录;换键会创建第二个业务操作。取消等待不会撤销已经发生的提交。","fresh":"换操作键重试","heading":"超时以后,重试会写几次?","history":"调用 / 操作键 / 结果","intro":"发送一次响应丢失的写入,再选择如何重试或查询。","known":"原操作的已知状态","none":"尚未发送","query":"查询原操作","reset":"重置","same":"同操作键重试","scope":"内存服务端保存去重记录;每个新键增加一次计数,最多四次请求。首次响应固定丢失。现实还需定义去重有效期与原子性。","scopeLabel":"模型范围与约定","send":"写入,响应丢失","unknown":"结果未知"}}
一个可运行的本地执行器示例
下面用预设模型响应模拟一次只读库存查询。它验证工具白名单、参数、调用结果关联与请求上限,不调用模型 API,也不执行真实业务写入。生产系统还需要持久状态、身份与权限、超时、完整协议适配和审计。
STOCK = {"A1": 3}
def execute(call):
if call.get("name") != "lookup_stock":
return {"ok": False, "error": "unknown_tool"}
args = call.get("input")
if not isinstance(args, dict) or set(args) != {"sku"}:
return {"ok": False, "error": "invalid_arguments"}
if not isinstance(args["sku"], str):
return {"ok": False, "error": "invalid_sku"}
if args["sku"] not in STOCK:
return {"ok": False, "error": "not_found"}
return {"ok": True, "available": STOCK[args["sku"]]}
def run(model, max_requests=3):
observations, seen = [], set()
for _ in range(max_requests):
response = model(list(observations))
if response["kind"] == "final":
return {"state": "model_ended", "text": response["text"],
"observations": observations}
if response["kind"] != "tool":
return {"state": "invalid_response"}
call = response["call"]
call_id = call.get("id")
if not isinstance(call_id, str) or not call_id or call_id in seen:
return {"state": "invalid_call_id"}
seen.add(call_id)
observations.append({"call_id": call_id, "result": execute(call)})
return {"state": "budget_exhausted", "observations": observations}
def scripted(observations):
if not observations:
return {"kind": "tool", "call": {
"id": "c17", "name": "lookup_stock", "input": {"sku": "A1"}}}
assert observations[0] == {
"call_id": "c17", "result": {"ok": True, "available": 3}}
return {"kind": "final", "text": "当前查询到可售数量为 3,尚未预留。"}
result = run(scripted)
assert result["state"] == "model_ended"
assert execute({"name": "delete_stock"})["error"] == "unknown_tool"
assert execute({"name": "lookup_stock", "input": {"sku": 1}})["error"] == "invalid_sku"
assert run(scripted, max_requests=1)["state"] == "budget_exhausted"
print(result["text"])
例子在一次工具调用后、还没取得最终响应时耗尽请求预算,会明确返回 budget_exhausted。这比将任何循环退出都映射为“成功”更准确。真实应用也应持久保存已经完成的调用,避免进程重启后丢失副作用记录。
工具边界与循环收敛
把“最多运行十轮”作为最后保险还不够。应观察每轮是否获得新证据、修改了产物、消除了错误;连续得到相同失败且条件没有变化时,重复动作通常无法推进。可以返回更具体的错误、改变检索范围,或报告缺失条件,而不是无限重发。
工具输出需要限制大小并保留可继续读取的位置。截断日志时明确标注截断区间、总量及文件定位,不能让模型误以为拿到了完整证据。上下文管理见 上下文工程。
专用工具将参数与业务语义暴露给执行器,便于校验、授权和记录。Shell 工具提供更通用的能力,但命令允许列表本身不足以限制程序所能做的全部操作;仍需通过执行身份、文件和网络访问范围、隔离环境与资源限制控制实际权限。具体策略应匹配已经授权的任务,不要让每一步可逆操作都退化为重复确认,详见 安全与防护。
补充:环境模拟器与真实执行
Agent 训练和评估可以使用环境模拟器:策略提出动作,模拟器产生观察,再让策略继续。语言世界模型尝试学习这种环境响应,例如 Qwen-AgentWorld 模型卡 描述了面向多类交互环境的模拟能力。这与真正修改文件或调用业务服务属于不同执行路径。
模拟器可能生成貌似合理但状态不一致的结果。评估时不仅看单次观察是否像真,还要看连续动作的因果一致性、资源约束与长期状态是否保持;模拟环境中的高分不能直接证明真实工具执行成功率。是否适合作为模拟器或 Agent,应分别测量,不能用激活参数量断言某类角色天然可行或不可行。
继续阅读:工具接口与 MCP。