`chat` 的交互模式:一次对话里发生了什么

2026-08-10

一个 REPL 看起来只有两件事:你敲一行,它回一段。但真要排查「我加的知识库怎么没生效」「我传的 config 被谁覆盖了」「工具输出为什么只剩十行」这类问题时,得知道这一轮里代码到底按什么顺序动了哪些状态。

DeepTutor CLI 的这一层收口在 deeptutor_cli/chat.py(377 行)与 deeptutor_cli/common.py(912 行)两个文件里。下面的行号与数字都对应我们采集的仓库快照:HEAD 456f9c2,采集日 2026-08-10,deeptutor/__version__.py 里的版本号是 1.5.11。你 clone 之后行号可能已经漂了,但文件名、字段名和常量名是稳的,照着搜即可。

先说清本文的边界:这篇只讲命令行这一层的交互机制——状态怎么存、参数怎么合并、输出怎么截断。它背后是什么教学策略、按什么规则判分,都不在本文范围内,也不做任何效果层面的说法。

一、chat 不是普通子命令,它是一个 callback

chat@app.callback(invoke_without_command=True) 注册(chat.py:43-60)。这行的实际后果是:deeptutor chat 后面不跟任何子命令时,直接落进 REPL,而不是打印帮助。

它的选项一共九个(chat.py:46-56):--session--tool/-t--capability/-c(默认 "chat")、--kb--notebook-ref--history-ref--language/-l(默认 "en")、--config--config-json。注意默认语言是 en 而不是 zh

--session 恢复一个已有会话时,有一步容易被忽略:capability、tools、knowledge_bases、language、notebook_refs、history_refs 这六项会从 session 的 preferences 里回填(chat.py:92-107)。也就是说,同一批字段既能来自命令行,也能来自会话记录。谁压谁,请自己打开这几行看那串兜底写法——我们只陈述「存在回填」这一个事实,不替它归纳优先级。

顺带一提,session open <id> 走的也是同一个 REPL:它直接复用 _chat_repl(ChatState(session_id=...))deeptutor_cli/session_cmd.py:31-35)。所以本文讲的所有交互行为,对 session open 同样适用。

二、进 REPL 的同时,还起了一个后台服务

_chat_repl 一开始会尝试 get_cron_service().start(),抛异常就把它置 None;退出时在 finally 里 stop(chat.py:83-90:177-180)。

这一点值得单独拎出来:你以为只是开了个对话框,进程里其实还多了一个定时服务的生命周期。它起没起成功不影响你继续聊,因为异常被吞掉了。

同一个 CLI 里另有一处也说明这个运行时不是纯粹的「只发 HTTP 请求」:servesys.platform == "win32" 时会切到 WindowsProactorEventLoopPolicy,注释写明原因是 uvicorn 默认的 SelectorEventLoop 不支持 asyncio.create_subprocess_execdeeptutor_cli/main.py:148-152)。这行注释出现在 serve 的启动路径上,说明该运行时里存在依赖 create_subprocess_exec 的代码路径。具体在什么场景下拉起什么程序、chat 这一层是否会走到,本文没有核实,也不下结论;关于 serve 那一层,另有篇目专门讲。

三、一行输入是怎么被读进来的(Windows 侧要单独看)

REPL 的读取函数是 _read_repl_input()chat.py:272-284):行尾是反斜杠就继续读下一行,续行提示符是 ...。多行粘贴不靠特殊快捷键,靠这个约定。

底层的 read_console_input()common.py:37-85。它在 UTF-8 的 tty 上会设置 termios.IUTF8,让退格按整个字符删除,而不是按字节退。名字里带 termios 已经说明了平台:这段处理是 POSIX 侧的。

Ctrl-C 的差别更需要 Windows 读者留意。common.py:264-274_SigintInterceptor docstring 明写:这个拦截器只在 POSIX 生效;在没有 add_signal_handler 的平台(也就是 Windows)上它退化为 no-op,Ctrl-C 落到 maybe_run 的优雅退出路径上。而 maybe_run() 的处理是用 asyncio.run 跑,捕获 KeyboardInterrupt 后打印 Interrupted. 并返回 None(common.py:857-862)。

所以「Ctrl-C 打断当前这一轮」与「Ctrl-C 结束这次调用」在两类平台上不是同一条代码路径。要确认自己这边是哪一条,去 common.py:264-274 读那段 docstring,再看你的平台有没有 add_signal_handler——这比试探性地按几次要可靠。

四、反直觉的那一处:/kb 是替换,不是追加

REPL 欢迎面板那段字符串里列出的命令是(chat.py:109-124):/quit/session/status/new/clear/regenerate(别名 /retry)、/tool on|off <name>/cap <name>/kb <name>|none/history add <id>/history clear/notebook add <ref>/notebook clear/show last|<n>/refs/config show|set|clear

把这张清单竖着读一遍就会发现语义不齐:/history/notebook 都是 add / clear 的双动词,明摆着是追加语义;/toolon|off 的开关语义;唯独 /kb 只有一个参数位。

它到底是哪种语义,代码只有一行:

state.knowledge_bases = [] if value == "none" else [value]

位置在 chat.py:219-221。这是赋值成单元素列表,不是 append。也就是说:

  • /kb a 之后再 /kb b,生效的只有 ba 被顶掉了;
  • /kb none 是清空,而不是”挂一个叫 none 的库”;
  • 顶层 run--kb 是 list 型选项、可以给多个(deeptutor_cli/main.py:85-95),而 REPL 里的 /kbchat.py:219-221 那行赋值决定只能留一个。

最后这条差异是最容易咬人的:同一个概念,在命令行选项那一侧按 list 收,在斜杠命令这一侧却是单元素赋值。你在 REPL 里随手敲一句 /kb 想”再加一个”,结果原先挂着的那个一起没了。

怎么确认自己撞的是这个:敲 /refs 看当前挂着哪些库——这是 REPL 里现成可用的确认动作,它就在欢迎面板的命令表里(chat.py:109-124)。如果状态里知识库确实只剩一个,那就是这条语义;如果状态里挂着的库是对的、检索结果仍然不对,那说明问题不在这一层,得往 kb 命令组和检索那一侧去查,别在这里继续耗。

五、--config--config-json 的合并顺序

一轮请求怎么组装,在 build_turn_request()common.py:842-843):config_json--config 的逐项去覆盖。顺序是固定的,不因你在命令行上谁写在前面而改变。

两侧的解析约束分别是:

  • --config KEY=VALUE:缺 = 或者 key 为空,直接抛 Invalid --config item ... Expected KEY=VALUE.common.py:92-99);
  • --config-json:必须是一个 JSON 对象,否则抛 JSON config must be an object.common.py:102-112)。传数组或裸标量都不行。

进了 REPL 之后,/config set 比命令行宽松一点:key=valuekey value 两种写法都认(_parse_config_assignmentchat.py:342-351)。值的类型判定是三级的(chat.py:366-377):先试 json.loads,失败后识别 true / false / null / none,再不行就当原样字符串。

这个三级判定有个直接后果:值写成 3 会被 json.loads 吃成数字,写成 gpt-4o 这种非 JSON 字面量则一路落到第三级、留成字符串。当你需要一个”看起来像数字的字符串”时,这条链会拦在前面——具体怎么绕,代码里没给专门的转义写法,我们也不替它发明一个。

还有一个格式约束单独记一下:notebook 引用的写法是 <notebook_id>:<record_id,record_id>notebook_id 为空会抛 typer.BadParameterchat.py:354-363)。

六、工具输出为什么只剩十行:deeptutor_cli/_tool_result.py

这个模块的职责写在文件开头(deeptutor_cli/_tool_result.py:1-17):把工具输出截断显示,同时把全文缓存起来供 /show 展开;docstring 还特意说明它是 pure logic,便于脱离 REPL 单测。

三个默认常量是:

常量默认值位置
DEFAULT_HEAD_LINES10_tool_result.py:27
DEFAULT_LINE_HARD_CAP240_tool_result.py:28
DEFAULT_RING_CAPACITY32_tool_result.py:33

前两个管”这一条显示多少”:默认只留头 10 行,超长单行按 line_hard_cap - 1 截断后追加 _clip_long_line_tool_result.py:71-76)。truncate_for_display() 返回 (visible_text, hidden_line_count),其中 hidden_line_count == 0 就代表全文已经装下了、没有被截(_tool_result.py:36-62)。渲染时打印的提示串是 #<index> <label> — +N more lines; run /show <index> (or /show last) to expandcommon.py:749-777)。

第三个管”能往回翻多少”,也是本文的第二处反直觉。ToolResultBuffer 是有界环,超过容量丢最旧的;但注释明写 index 保持递增语义,/show 7 仍然指”本次会话第 7 个结果”(_tool_result.py:102-111)。这两句话合在一起是:编号不会回绕,但编号指向的那条可能已经不在环里了。一次长会话跑出上百个工具结果之后,/show 7 是拿不回来的,而不是拿回第 7 条以外的别的东西。

/show 的参数解析规则是三条(get(selector)_tool_result.py:125-141):None、空串、"last" 都取最近一条;纯数字按 1-based index 取;其他字符串按 label 匹配、取最近命中的那条。

最后一个小细节:这个 buffer 是模块作用域的单例 tool_resultscommon.py:26-30),注释说明 deeptutor run 这种单次模式不读它,但仍然会往里写。

七、README 与代码对不上的两处

deeptutor_cli/README.md 里核对这一章时,会撞上两处可核实的差异。按纪律只陈述位置与差异,不推断原因,也不据此评价项目:

  1. README 的 REPL 命令表(README.md:135-151)里没有 /status/clear;代码里两者都实现了(/statuschat.py:197-199/clear/new 同义,在 chat.py:200-203),欢迎面板那段字符串也把它们列了出来(chat.py:113)。
  2. README 的 chat 选项表(README.md:127-134)只列了 5 个选项,而代码里是 9 个,--notebook-ref--history-ref--config--config-json 四个不在表内(chat.py:50-56)。

以我们实读的仓库状态为准。说完就停。

实用含义只有一条:查这一层的可用命令与选项时,--help 的输出和源码是同一批注册信息,而 README 这张表是另一份维护物。

八、你可以照着核的六步

  1. 打开 deeptutor_cli/chat.py:43-60,确认 chat@app.callback(invoke_without_command=True),以及九个选项各自的默认值(尤其 --language 默认 en)。
  2. 打开 deeptutor_cli/chat.py:219-221,把那一行赋值语句抄下来,确认 /kb 是替换语义。
  3. 打开 deeptutor_cli/chat.py:92-107,看恢复 session 时哪六项会被 preferences 回填。
  4. 打开 deeptutor_cli/common.py:842-843,确认合并顺序是 config_json 在前、--config 逐项覆盖在后。
  5. 打开 deeptutor_cli/_tool_result.py:27:28:33,抄下三个默认常量;再读 :102-111 的注释,确认 index 不回绕而条目会被丢。
  6. deeptutor_cli/README.md:127-134:135-151 两张表和 deeptutor_cli/chat.py:46-56:109-124 对着读一遍,自己数一遍差几项。

最后交代边界:common.py 里那个约 380 行的事件流状态机 TurnStreamRenderercommon.py:316-692)我们只读了首尾与少数辅助函数,逐事件渲染分支没有逐行核实,所以”一轮回答在流式过程中是怎么分段呈现的”本文不下结论。上面出现的所有默认值都是源码里的默认配置,不是运行结果的保证;各命令的实际退出码与真实终端下的表现,我们也没有验证。


本文依据 DeepTutor 官方仓库(github.com/HKUDS/DeepTutor)的 README、AGENTS.mdpyproject.tomldeeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。 本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API, 因此不涉及生成质量、响应速度与教学效果的任何描述。 参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。 安全相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。