`chat` 的交互模式:一次对话里发生了什么
一个 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 请求」:serve 在 sys.platform == "win32" 时会切到 WindowsProactorEventLoopPolicy,注释写明原因是 uvicorn 默认的 SelectorEventLoop 不支持 asyncio.create_subprocess_exec(deeptutor_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 的双动词,明摆着是追加语义;/tool 是 on|off 的开关语义;唯独 /kb 只有一个参数位。
它到底是哪种语义,代码只有一行:
state.knowledge_bases = [] if value == "none" else [value]
位置在 chat.py:219-221。这是赋值成单元素列表,不是 append。也就是说:
/kb a之后再/kb b,生效的只有b,a被顶掉了;/kb none是清空,而不是”挂一个叫 none 的库”;- 顶层
run的--kb是 list 型选项、可以给多个(deeptutor_cli/main.py:85-95),而 REPL 里的/kb由chat.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=value 和 key value 两种写法都认(_parse_config_assignment,chat.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.BadParameter(chat.py:354-363)。
六、工具输出为什么只剩十行:deeptutor_cli/_tool_result.py
这个模块的职责写在文件开头(deeptutor_cli/_tool_result.py:1-17):把工具输出截断显示,同时把全文缓存起来供 /show 展开;docstring 还特意说明它是 pure logic,便于脱离 REPL 单测。
三个默认常量是:
| 常量 | 默认值 | 位置 |
|---|---|---|
DEFAULT_HEAD_LINES | 10 | _tool_result.py:27 |
DEFAULT_LINE_HARD_CAP | 240 | _tool_result.py:28 |
DEFAULT_RING_CAPACITY | 32 | _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 expand(common.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_results(common.py:26-30),注释说明 deeptutor run 这种单次模式不读它,但仍然会往里写。
七、README 与代码对不上的两处
在 deeptutor_cli/README.md 里核对这一章时,会撞上两处可核实的差异。按纪律只陈述位置与差异,不推断原因,也不据此评价项目:
- README 的 REPL 命令表(
README.md:135-151)里没有/status与/clear;代码里两者都实现了(/status在chat.py:197-199,/clear与/new同义,在chat.py:200-203),欢迎面板那段字符串也把它们列了出来(chat.py:113)。 - README 的
chat选项表(README.md:127-134)只列了 5 个选项,而代码里是 9 个,--notebook-ref、--history-ref、--config、--config-json四个不在表内(chat.py:50-56)。
以我们实读的仓库状态为准。说完就停。
实用含义只有一条:查这一层的可用命令与选项时,--help 的输出和源码是同一批注册信息,而 README 这张表是另一份维护物。
八、你可以照着核的六步
- 打开
deeptutor_cli/chat.py:43-60,确认chat是@app.callback(invoke_without_command=True),以及九个选项各自的默认值(尤其--language默认en)。 - 打开
deeptutor_cli/chat.py:219-221,把那一行赋值语句抄下来,确认/kb是替换语义。 - 打开
deeptutor_cli/chat.py:92-107,看恢复 session 时哪六项会被preferences回填。 - 打开
deeptutor_cli/common.py:842-843,确认合并顺序是config_json在前、--config逐项覆盖在后。 - 打开
deeptutor_cli/_tool_result.py:27、:28、:33,抄下三个默认常量;再读:102-111的注释,确认 index 不回绕而条目会被丢。 - 把
deeptutor_cli/README.md:127-134、:135-151两张表和deeptutor_cli/chat.py:46-56、:109-124对着读一遍,自己数一遍差几项。
最后交代边界:common.py 里那个约 380 行的事件流状态机 TurnStreamRenderer(common.py:316-692)我们只读了首尾与少数辅助函数,逐事件渲染分支没有逐行核实,所以”一轮回答在流式过程中是怎么分段呈现的”本文不下结论。上面出现的所有默认值都是源码里的默认配置,不是运行结果的保证;各命令的实际退出码与真实终端下的表现,我们也没有验证。
本文依据 DeepTutor 官方仓库(github.com/HKUDS/DeepTutor)的 README、AGENTS.md、
pyproject.toml 与 deeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。
本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API,
因此不涉及生成质量、响应速度与教学效果的任何描述。
参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。