深入解析 langchain-quickjs 系统提示词:沙箱 JavaScript REPL、子代理编排与 tools 命名空间
【免费下载链接】deepagentsThe batteries-included agent harness.项目地址: https://gitcode.com/GitHub_Trending/de/deepagents
本篇技术指南以 deepagents 生态中 langchain-quickjs 集成模块的系统提示词快照(即quickjs_system_prompt_mixed_foreign_functions.md)为核心线索,逐段拆解这条被注入到 Agent 上下文中的提示词:它如何定义eval沙箱 REPL 的运行语义,如何引导模型用task()在 JavaScript 内编排配置化子代理,以及如何通过tools.*命名空间在 REPL 内部直接调用宿主 Agent 的 LangChain 工具。读完本文,你将完整掌握这套"一次性 eval 调用完成循环、并行、多阶段编排"的提示词设计,以及其背后在 middleware.py、_repl.py、_prompt.py 等源码中的落地实现。
一、这条系统提示词从何而来:快照测试的产物
快照文件不是手写的文档,而是由 tests/unit_tests/smoke_tests/test_system_prompt.py 通过真实渲染CodeInterpreterMiddleware注入的系统提示词后提交进仓库的"黄金样本"(golden snapshot)。
该测试文件的核心逻辑是:
- 用
GenericFakeChatModel假模型构造一个create_deep_agentAgent,挂载CodeInterpreterMiddleware; - 触发一次
invoke,从模型的call_history中取出最终SystemMessage,转成纯文本; - 与
snapshots/目录下对应快照逐字节比对(_assert_snapshot); - 快照以三种持久化模式(
thread/turn/call)分别生成:_snapshot_name_for_mode会为thread使用基础名,turn/call追加后缀(test_system_prompt.py#L170-L175)。
"mixed foreign functions" 这一名称来自测试用例test_system_prompt_snapshot_with_mixed_foreign_functions:它把find_users_by_name、get_user_location、get_city_for_location、normalize_name、fetch_weather这五个自定义(foreign)工具同时传入ptc=mixed_tools与tools=mixed_tools,从而生成一份"PTC 工具混入子代理 task 能力"的完整提示词——这正是快照文件名中 "mixed foreign functions" 的含义。快照测试的用途是防止 deepagents SDK 自身的提示词漂移悄悄改变 quickjs 中间件所组合的提示词,因此在 SDK 变更而 quickjs 未变时,CI 会单独运行该 smoke 测试;如需在有意修改提示词后重新生成快照,运行pytest ... --update-snapshots即可。
二、Interpreter 段:eval工具的运行契约
快照的第一段### Interpreter定义了模型看到的eval工具语义,渲染逻辑位于 _prompt.py 的render_repl_system_prompt。逐条拆解如下:
1. 持久化 REPL 与顶层 await
- 状态跨调用、跨轮次持久:变量与函数在本次会话线程内持续存在。对应源码里
mode="thread"分支的渲染(_prompt.py#L295-L303),每个 LangGraphthread_id拥有独立 QuickJS 槽位(worker + Runtime + Context),会话间互不可见(_repl.py#L337-L343)。 - 顶层
await有效:Promise 会在调用返回前解析。对应 middleware.py 的async_eval与 _repl.py#L801-L807 中的await_promise处理——最终表达式返回 Promise 时先等待其 resolve 再序列化结果。
2. 运行时沙箱
提示词明确声明:没有内置的文件系统、网络、stdlib 或墙钟 API。fetch、require、fs、process、真实Date.now()均不可用或被 stub。这由 QuickJS 上下文本身保证——REPL 运行在一个"零环境能力"的 QuickJS 沙箱中(README 的 Sandbox 一节做了同样说明),所有外部副作用只能通过tools.*命名空间显式触达。
3. 超时与内存
- 超时:单次调用 5.0 秒,对应中间件默认
timeout=5.0(middleware.py#L57)。注意源码特别提示:该预算度量的是 QuickJS VM 执行时间,而非 Python 墙钟时间——awaittools.*宿主调用期间(Python 协程)不占用此预算(middleware.py#L125-L132)。 - 内存:总共 64 MB,对应默认
memory_limit=64 * 1024 * 1024字节,且在同一个 Runtime 下的所有 Context 之间共享(middleware.py#L56)。
4. console.log 捕获
console.log输出会被捕获并随结果一并返回。对应 _repl.py 的_ConsoleBuffer与_install_console(安装__console_log/__console_warn/__console_error三个宿主函数并组装globalThis.console,见 _repl.py#L410-L438)。捕获的 stdout 单独以<stdout>块返回,且与结果分开截断。
补充要点:本快照对应的中间件配置开启了 PTC(
ptc=mixed_tools),因此 Interpreter 段的副作用行是"External side effects ... only reachable via thetools.*namespace";而在无工具的快照quickjs_system_prompt_no_tools.md中,这行会变成"REPL has no access to host tools, files, or the network: it is pure computation"——这正是render_repl_system_prompt的ptc_attached开关控制的两种文案(_prompt.py#L276-L285)。
三、task:在 REPL 内编排配置化子代理
快照的第二大段### Dispatching Subagents with task是这条提示词中最长的部分,也是 deepagents 将"子代理编排"能力下沉到 JavaScript 的关键设计。它告诉模型:你的职责是分发工作,而不是自己完成全部工作——用 JS 把任务扇出(fan out)给子代理,再组装它们的结果;扇出、过滤、去重、多阶段流转、综合(synthesis)全部用普通 JavaScript 完成。
1. 原语签名
await task({ description, // 完整自主任务提示词 subagentType, // 已配置的子代理名称 label, // 可选:用于实时进度 UI 的短标签 responseSchema, // 可选:结构化输出的 JSON Schema }); // -> Promise<unknown>各字段语义与源码对应关系:
description:该次分发中子代理收到的唯一提示词,必须自洽完整(目标、约束、要检查什么、期望返回的形状与详细程度)。提示词还强调:"Give context as locators — file paths and symbol names — not as pasted file contents"(给定位符而非粘贴内容),并说明每次分发对调用方是无状态的,不能对同一子代理运行追加后续消息。subagentType:必填,必须是已配置的子代理名称。对应 _subagent.py 的_validate_task_payload中description/subagentType均为非空字符串的校验。label:可选,仅在实时进度 UI 中展示,不会发给子代理,也不影响执行。源码中 label 为空串会被归一化为None(_repl.py#L512-L518)。responseSchema:可选但强烈建议——凡是结果要喂给后续代码的分发都应设置。有了确定性的类型化形状,下一阶段才能可靠地组合(索引、排序、字段比较、分支、合并),而不必解析自由文本。注意两点约束:- 提供了 schema 后,resolve 出的值已经是匹配 schema 的类型化 JS 值,除非子代理故意返回 JSON 字符串,否则不要再调用
JSON.parse; - 动态 schema 对声明式(declarative)子代理有效,而 runnable 支撑的子代理会拒绝动态 schema(其 runnable 已被编译)。
源码侧对 schema 施加了硬限制(_subagent.py#L33-L40 与
_validate_response_schema实现):序列化后 ≤ 4096 字节、嵌套深度 ≤ 5、总属性数 ≤ 32。超出即抛出ValueError并在 eval 结果中以错误呈现。- 提供了 schema 后,resolve 出的值已经是匹配 schema 的类型化 JS 值,除非子代理故意返回 JSON 字符串,否则不要再调用
2. 审批模型(Approval model)
提示词明确声明:task是在已运行的eval调用内部发起分发的,不经过父 Agent 的ToolNode管理的task工具路径,也不为每次分发触发父级的interrupt_on/ HITL 审批。声明式子代理仍会遵守其 spec 内部配置的审批中间件。若需在父级发起子代理前审批,应使用 JavaScript 之外的普通task工具,或确保eval调用本身受审批门控。
这与 middleware.py 的subagents参数警告 完全一致:task(...)在已被批准的 eval 调用中运行,不触发父级每次分发的 HITL;需要时可用subagents=False关闭该能力,强制走父级task工具路径。
3. 心智模型与有界并发
提示词给出的核心心智模型是:在 JS 里持有你的工作集——一数组输入、一数组输出,把每次分发结果合并回对应条目。多阶段分析即:跑一遍,在 JS 中过滤/重组数组,再对幸存者跑下一遍。
并发方面,建议用Promise.all并行分发独立工作,但显式按约 10 个一批分批,避免一次性启动数百个子代理。这是硬约束:桥接层强制单 REPL 最多32个并发子代理调用。对应源码 _repl.py#L63 的_MAX_TASK_CALLS_PER_THREAD = 32,通过asyncio.Semaphore(32)实现(_repl.py#L400)。快照给出了完整的批量审查示例(SQL 注入扫描 + 行号引用 + 结构化 schema),可直接复用。
4. 先探索、后分发
提示词强调:模型本来就有读取、列目录、glob、grep 的常规工具,应先用它们完成探索,再写编排脚本;绝不要写eval代码仅仅为了让子代理去读文件或列目录——那是确定性步骤,直接用工具调用完成,为一个 agentic loop 付费是浪费。理解了工作形状后,分片方式有创意自由:
- 条目天然分离时:每个文件/每条记录分发一次;
- 大输入自行切块(读取、切分、必要时每块写个小输入文件),每块分发一个子代理;
- 先做廉价分类,只对值得深挖的条目做更深分发。
同时重申 "locator, not payload":子代理有自己的文件工具,文件类任务传路径让子代理自己读;只有无路径的小型/派生数据(单条解析记录、切出的块)才内联进 description。结果在 JS 中组装。
5. 多阶段组合
快照给出了"廉价分类 → 过滤出风险项 → 只对风险项深审"的两阶段示例(tagged→riskyHandlers→deepReviews),并展示了.then((tag) => ({ file, ...tag }))这种把分发结果合并回条目的惯用法。
6. 用最后一个表达式返回结果,而非 console.log
eval调用中最后一个表达式的值(或 resolve 的顶层 await)就是返回给模型的结果。console.log只用于附带调试:其输出会被截断,而返回值不会——所以永远不要用console.log输出真正结果。大的中间集合留在 JS 变量中,只返回紧凑摘要或小切片;要持久化完整输出,让子代理写文件,或用自己 eval 外的文件工具写。对应 _repl.py#L801-L821 的"取 handle → 若为 Promise 则 await → marshal 为字符串"流程,以及 _format.py 的format_handle(函数值回退为[Function] arity=N形式)。
7. 复用前面 eval 留在作用域里的东西
REPL 在轮次内持久:每个顶层声明的变量、函数、类都会提升到全局作用域,供下一次eval调用使用。提示词给了一个很强的自查信号:如果你发现自己正在把上一次调用产生的大数组/对象重新敲成一个新的字面量,那就是提示你——变量还在作用域里,直接按名字引用它。重打先前结果既浪费 token,又会偏离实际运行过的数据。快照中的auditResults→findings→verified三连示例展示了跨调用引用与二次分发验证的写法。
8. 用户提到 "workflow" 时
提示词的最后一个规则:当用户请求中提到 "workflow"(或类似措辞),应把工作扇出给子代理而不是自己一件件做完——必要时先自己探索,然后在eval里写 JS 用task()分发并组装结果。要点是并行分发重活,而不是一次一个工具调用地硬磨。
四、tools命名空间:程序化工具调用(PTC)
快照第三大段### API Reference — tools namespace是 PTC(Programmatic Tool Calling)的系统提示词部分。当中间件配置了ptc=[...]时,Agent 的工具被以globalThis.tools.<camelCaseName>的形式暴露进 REPL;对应实现分散在 _ptc.py(筛选)与 _repl.py(宿主函数桥接)。
1. 调用模式
每个工具接收单个对象参数,返回一个 Promise,resolve 为工具的原生值:字符串是字符串、数字是数字、列表是数组、字典是对象、None是null。不需要JSON.parse——已经是类型化的了。这得益于 _format.py 的coerce_tool_output_for_ptc:它在桥接边界解开 LangChain 的ToolMessage/Command信封,递归保留 JS 原生形状,无法原生 marshal 的嵌套值原地str(value)。
调用范式:await tools.<name>({ ... })。
2. 使用规则(提示词内的核心规范)
- 用
await获取工具结果;独立调用用Promise.all并发执行。 - 优先单次 eval 内完成多个工具调用,而不是拆到多次 eval——每次往返都消耗一次模型 turn。
- 同一程序内链式管道调用:一个工具的结果是另一个工具的输入时,直接在一个程序里串联,别把中间值返回给模型再传回来。
- 工具返回的 ID 或其他可直接透传的值,信任它并直接链式调用,不要停下来反复确认。
- 想检查中间值,在同一程序内
console.log即可;否则一次调用内尽量多取信息。 - 只有在"没有额外模型推理或用户输入就无法决定下一步"时,才把工作拆到多次 eval。
3. 快照中的示例形状
const users = await tools.findUsers({ name: "Ada" }); const userId = users[0].id; const [city, normalized] = await Promise.all([ tools.cityForUser({ user_id: userId }), tools.normalize({ name: "Ada" }), ]); console.log({ city, normalized });随后是每个暴露工具的 TypeScript 风格签名块(由工具注释首行 + 参数 schema 渲染而来):
/** Find users with the given name. */ tools.findUsersByName(input: { name: string; }): Promise<unknown[]> /** Get the location id for a user. */ tools.getUserLocation(input: { user_id: number; }): Promise<number> /** Get the city for a location. */ tools.getCityForLocation(input: { location_id: number; }): Promise<string> /** Normalize a user name for matching. */ tools.normalizeName(input: { name: string; }): Promise<string> /** Fetch the current weather for a city. */ tools.fetchWeather(input: { city: string; }): Promise<string>注意工具名被自动转换为 camelCase(find_users_by_name→findUsersByName)。这些签名正是 test_system_prompt.py 中定义的五个工具,由 _prompt.py 的render_ptc_prompt从工具 args schema 渲染:to_camel_case处理命名转换,_render_signature依据_json_schema_to_ts把 JSON Schema 映射为 TS 类型(支持enum、anyOf、嵌套对象、数组,fallback 为Record<string, unknown>)。
4. 桥接机制的源码级说明
- 每个 PTC 暴露的工具都会在 QuickJS Context 上注册一个异步宿主函数桥,符号形如
__tools_<name>_<hash>(_repl.py#L310-L320),因此 JS 侧看到的是返回 Promise 的异步函数。 globalThis.tools每个 turn 从当前暴露名集合重建(_repl.py#L440-L490),若上游中间件按 turn 过滤工具,tools命名空间会跟着变。- 桥调用时转发外层 eval 捕获的
ToolRuntime与 runnable config(state、store、context、callbacks、合成的子tool_call_id),见 _repl.py 的_inject_tool_args_for_ptc。 - 每 eval 有
tools.*调用预算(默认 256 次,_DEFAULT_MAX_PTC_CALLS),超限在宿主函数桥内抛错,未捕获时呈现为PTCCallBudgetExceeded(_repl.py#L823-L830)。max_ptc_calls=None可关闭预算,但存在 DoS 风险,仅应在可信环境使用。 - PTC 调用不经过
ToolNode路径,因此interrupt_on/HITL 不会对每个 PTC 工具调用生效;PTC 白名单本身即安全边界。task工具被保留(reserved),不能通过ptc暴露为tools.task——它永远以顶层task()全局存在,暴露会造成冲突的分发路径并丢失responseSchema(_ptc.py#L39-L45)。
五、三种持久化模式与配置参考
虽然本快照是thread模式(快照命名不带后缀),理解另外两种模式有助于读懂提示词中每一处 "persists" 措辞。三种模式由 middleware.py 的mode参数 控制:
| 模式 | 状态持久范围 | 提示词对应文案 |
|---|---|---|
thread(默认) | 跨调用、跨轮次,同 LangGraph thread 内 | "persists across tool calls and across multiple turns for this conversation thread" |
turn | 单轮内跨调用,轮次之间重置 | "persists across tool calls within a single turn" |
call | 每次 eval 全新环境 | "runs JavaScript in a fresh sandboxed REPL for each invocation" |
render_repl_system_prompt对三种模式分别渲染 intro 行与状态行(_prompt.py#L286-L312),render_eval_tool_code_doc/render_eval_tool_description同理(_prompt.py#L327-L369)。call模式下每次 eval 后还会reset_repl丢弃槽位(middleware.py#L311-L312)。
CodeInterpreterMiddleware 完整配置
from deepagents import create_deep_agent from langchain_quickjs import CodeInterpreterMiddleware agent = create_deep_agent( model="claude-sonnet-4-6", middleware=[ CodeInterpreterMiddleware( memory_limit=64 * 1024 * 1024, # 字节,同 Runtime 下所有 Context 共享 timeout=5.0, # 单次调用秒数(QuickJS VM 执行时间) max_ptc_calls=256, # 单 eval 内 tools.* 桥调用次数;None 禁用预算(DoS 风险) tool_name="eval", # 暴露给模型的工具名 max_result_chars=4000, # 结果与 stdout 各自截断长度 capture_console=True, # 安装 console.log/warn/error 桥 subagents=True, # 宿主有 Deep Agents task 工具时暴露顶层 task(...) mode="thread", # "thread" | "turn" | "call" max_snapshot_bytes=None, # 默认等于 memory_limit;超限快照被丢弃 ptc=None, # None | list[str] | list[BaseTool] ) ], )其中ptc支持三种形态:CodeInterpreterMiddleware()默认关闭;ptc=["search_web"]按名白名单;ptc=[search_tool]直接传工具实例。REPL 自身工具永远被排除(避免tools.eval("tools.eval(...)")递归),task名被保留不可列入白名单。工具名必须能映射为合法 JS 标识符(/^[A-Za-z_$][A-Za-z0-9_$]*$/),否则抛ValueError(_ptc.py#L134-L144)。
模型可见的错误类型
| 类型 | 成因 | 对应源码路径 |
|---|---|---|
SyntaxError/TypeError/ReferenceError等 | 用户代码抛错,原样保留 JS 错误名 | _repl.py#L849-L851 |
Timeout | 调用超过timeout= | _repl.py#L831-L834 |
OutOfMemory | Runtime 触及memory_limit= | _repl.py#L856-L859 |
PTCCallBudgetExceeded | 单 eval 内tools.*调用预算超限 | _repl.py#L823-L830 |
Deadlock | 顶层 Promise 永不 resolve 且无进行中的异步宿主工作 | _repl.py#L835-L843 |
ConcurrentEval | 同一 Context 上的并发 eval(防御性映射) | _repl.py#L852-L855 |
当 JS 拒绝捕获HostCancellationError时,asyncio.CancelledError会干净地向外传播,保证 LangGraph 的取消语义端到端生效(_repl.py#L844-L848)。
六、如何用好这条提示词:实践要点总结
从快照文本中可以提炼出一套可复用的实战守则,无论是训练新的 Agent 工作流,还是审查基于 quickjs 的 Agent 行为,都值得对照检查:
- 编排在 JS 内完成:循环、聚合、过滤、去重、分支都写在
eval代码里,一次调用内做完,减少模型往返。 - 分发前先探索:读文件、列目录、grep 用常规工具,确定性步骤不花子代理的钱。
- 传 locator 不传 payload:文件类任务给路径,让子代理自读;只有小体积派生数据才内联。
- 始终设置 responseSchema:凡结果要喂给后续阶段的分发,用确定性形状换取可组合性;注意 4096 字节 / 深度 5 / 32 属性三个上限。
- 并发有界:
Promise.all并行 + 按 10 一批,桥层 32 并发硬上限是安全网而非常规并发量。 - 用最后一个表达式返回:结果走返回值,
console.log只做调试;大中间集留在 JS 变量里复用,不重打字面量。 - 记住审批边界:
task()与tools.*调用都不触发父级 HITL——安全敏感场景要么门控eval本身,要么subagents=False关闭。
这条系统提示词的设计目标可以一句话概括:把"工具调用循环"从模型逐轮往返的串行过程,压缩为 REPL 内的一次性 JS 脚本执行——循环、并发、链式调用、多阶段子代理编排,全部在一个eval调用内完成。快照测试保证了这份提示词在 SDK 演进过程中不会静默漂移;而 _prompt.py、_repl.py、_subagent.py 与 middleware.py 则完整实现了提示词声明的每一项语义。若想进一步深入,可阅读 README.md 的 PTC 章节 与 test_end_to_end.py 等集成测试,观察真实调用链与事件流。
【免费下载链接】deepagentsThe batteries-included agent harness.项目地址: https://gitcode.com/GitHub_Trending/de/deepagents
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考