DeepSeek V4 Flash (ds4.c) を Lisp 的に扱う
はじめに
最近は新しい LLM が出ても、llama.cpp の対応が遅かったりして困っていたんですが、DeepSeek V4 Flash 専用のds4.c(DwarfStar 4)が出ました。これで284Bモデルがローカルで動く!
ClaudeCode/OpenCode なんかを使えばいいんだろうけど、ClaudeCode は中で何やってるかわからん。OpenCode は大きすぎる。素朴に実装してみる。これは非常に面白い。皆作った方がいい。
でも、途中で飽きて Lisp 風に自分自身を書き換える事が出来れば面白いかも。と思って作りました。
https://github.com/Flowers-of-Romance/lispy
Q : nil って何やねん。
A : Python の None です。Lisp 風の nil ではありません。
Q : DeepSeek V4 Flash ってどう?
A : 悪くないです。ただ、長時間処理をさせていると、カーネルパニックは起きないんですが、端末が熱〜くなって、やばいです。単純に ds4 サーバの起動引数が悪かっただけかもしれませんが。
Q : メモリ機能はどこに配置?
A : メモリ機能はないです。自分好みになるだけで、結論がそれに引きづられるのが嫌いです。lispy では忌まわしきコンテキスト汚染として、読み込んでいません。必要であれば、lispy.SYSTEM_PROMPT.md などのシステムプロンプトに埋め込むことは可能です。
Q : "renew" ?
A : Claude 等は1Mコンテキストを扱うという触れ込みですが、20%も使えば、「今日はここまで。以降は明日」とか言って終わらせようとしてきます。我々は斜め下をいきます。LLM が「ああ、コンテキスト多いな」と感じたら、LLM が新しいセッションを始めます。この時に、普通は要約を残して、引き継ぎます。でもね、LLM って基本要約ですやん。そこからまた要約なんて耐えたられない。というそれだけのために、無駄な検索機能があります。
Q : 括弧多すぎやろ。
A : はい。なので、nl.py で自然言語で lispy.py を呼び出す様にしていますが、大分これは中途半端です。サーバを建てて、別 LLM から lispy.py を呼び出し、評価を委譲することもできます。
# ds4起動 cd ds4 && ./ds4-server --ctx 50000 --kv-disk-dir ~/ds4-kv --kv-disk-space-mb 32768 --trace ~/ds4-trace.txt # ログ確認 tail -f ~/ds4-trace.txt # powermetrics確認 sudo powermetrics --samplers cpu_power,gpu_power,thermal -i 1000 # Lispyサーバ起動 .venv/bin/python server.py --port 9000 --yolo
Scheme 風の評価器に LLM 呼び出しを混ぜています。
特徴は、agent loop が Python の関数ではなく S 式の binding になっている、という点。
何を言っているのか意味がわからないですね。説明してみます。(勿論 LLM が)
Claude Code の中身を想像してみる
Claude Code や Cursor のような agent 型ツールが、内部で何をやっているかを単純化して書くと、こんな感じ。(妄想)
def agent_step(messages, user_input):
messages.append({"role": "user", "content": user_input})
response = llm_call(messages)
messages.append(response)
if response.tool_calls:
for tc in response.tool_calls:
result = dispatch_tool(tc.name, tc.args)
messages.append({"role": "tool", "content": result})
return agent_step(messages, "")
return response
「ユーザ入力を履歴に積む -> LLM を呼ぶ -> tool 呼び出しがあれば実行して履歴に追記 -> tool があったらもう一周」、これだけ。実際の Claude Code はもっと複雑だが、本質は変わらない。
ここで重要なのは、この loop が Python (や TypeScript) の関数として固められている、という事。ループの規則を変えたければ、ファイルを編集して再起動するしかない。「LLM 呼び出しの前に critique を挟みたい」「tool 呼び出し前に user に確認させたい」みたいな小さな変更でも、コードを直して再ビルドする必要がある。
三層が時間軸で分かれている。ウォーターフォール的な構造。
agent loop を S 式の binding にする
lispyのagent loop はこう書かれています。
(define agent-step
(lambda (env input)
(let ((env2 (append-turn env (make-turn "user" input))))
(let ((response (llm-call env2)))
(let ((env3 (append-turn env2 response)))
(if (has-tool-calls? response)
(agent-step
(fold (lambda (e tc)
(append-turn e
(make-turn "tool"
(dispatch-tool (tool-call-name tc) (tool-call-args tc))
(tool-call-id tc))))
env3
(tool-calls response))
"")
response))))))
擬似コードの Python 版とやっていることは同じ。違いは、これが走行中の REPL の binding として存在している事です。
Python 側に残っているのは「LLM を 1 回呼ぶ」「tool を 1 つ実行する」「turn を 1 つ作る/追加する」といった単発の primitive だけ。
llm-call,
dispatch-tool,
append-turn,
make-turn,
has-tool-calls?,
tool-calls,
tool-call-name,
tool-call-args,
tool-call-id, これらが Python で書かれた基盤。
その上に乗っかる「呼び出しと結果と再帰の組み立て方」。要するに、agent loop の規則そのものは、評価対象のデータとして REPL に置かれている。
走行中に loop を書き換える
何ができるか。REPL を再起動せずに agent loop を差し替えられる。
main> 富士山の標高は? 富士山の標高は 3,776 m です。 main> (define agent-step ... (lambda (env input) ... (begin ... (print "[呼び出し直前] input:" input) ... (append-turn env (make-turn "user" input)) ... (let ((response (llm-call env))) ... (begin ... (append-turn env response) ... response))))) main> 北海道について 1 行で [呼び出し直前] input: 北海道について 1 行で 北海道は日本最北の島で、広大な自然と冷涼な気候、…
(define agent-step ...)を打った時点で、評価規則が更新される。次の入力からは新しい loop が走る。再起動なし、ファイル編集なし。
agent-step を LLM 自身に書き換えさせることもできる:
main> (prompt (string-append ... "次は agent-step の現行定義: " ... (to-sexp (lambda-body (lookup "agent-step"))) ... " これを、tool 呼び出し前に critique を挟む版に書き換えて。" ... "S 式 1 つだけで返す。説明禁止。"))
返ってきた S 式を
(eval (read-sexp ...))で取り込めば、評価器が自分の規則を LLM 由来で更新する。metacircular の極端な姿。
なぜそれが嬉しいの?
agent を書いていると、「ここで critique を挟みたい」「ここで cost を測りたい」「ここで retry を入れたい」が無限に湧いてくる。普通の作り方では、思いつくたびにコードを直して再起動して、状態を作り直して、再現させて、確認する。フィードバックループが長い。
lispy では、思いついたものを REPL でその場で試せる。状態(turns / archive / bindings)はそのまま残っているので、再現コストがほぼゼロ。気に入らなければまた書き換える。「実験」と「実装」の境界が消える。
これは Lisp が昔からやってきたこと(REPL で関数を再定義する文化)を、agent loop という対象に適用しただけです。Common Lisp の Condition System や Smalltalk の "live programming" の系譜。
どうしてこれが Lisp でできるのか
なぜこの設計が Lisp じゃないと難しいか。
Lisp は code = data という性質を持っている。
(+ 1 2)と書いたとき、これは「1 と 2 を足す式」であると同時に、「3 つの要素を持つリスト」でもある。Lisp から見ると区別がない。マクロというものは、リストを受け取ってリストを返す関数で、返ったリストが次に評価される。
この性質があると、「プログラムを書く道具」と「プログラムを書くプログラムを書く道具」が同じものになる。
defineで binding を作る道具と、
defineで agent loop を作る道具が、同じ
define。
Python だと、
defで関数を作るのと、関数を書き換えるのは別の操作になる。
func.__code__を差し替えたり
execで動的生成したりはできるが、それは普通の
defとは別世界の操作で、IDE は色を付けてくれないし、デバッガはついてこない。
Lisp では、
(define agent-step ...)を最初に打つときも、書き換えるときも、まったく同じ操作。差し替えと定義の区別がない。だから「走行中に loop を書き換える」が書ける。
自分自身を書き換える
agent-stepは、自分の中で
llm-callを呼ぶ。LLM に「現在の agent-step の定義はこうだ、改良版を S 式で返して」と頼んで、返ってきたものを
(eval (read-sexp ...))で取り込むと、agent が自分の評価規則を自分で書き換えることになる。
技術的には循環していない。
(define agent-step ...)を打った時点で、評価器は
env.bindings["agent-step"]を新しい値で上書きする。次の入力からは新しい方が引かれる。古い方は名前が上書きされて参照されなくなる。Python でクラスのメソッドを差し替えるのと、機械としては同じ操作。
eval
1960 年、John McCarthy が「Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I」という論文を書いた。Lisp の元論文として知られているが、その中で一番面白いのは、論文の最後の方にある一節。つまり、Lisp の評価器 (
eval) を Lisp で書いて見せた箇所。
7 行ほどで書かれている。
evalは S 式を受け取って、それを評価した値を返す関数。これを Lisp 自身の構文で書くことができる、ということは、Lisp が自分自身を Lisp で表現できるということ。
これがなぜ可能なのかというと、Lisp において「プログラム」と「データ」が同じ表現を持つから。
(+ 1 2)は「1 と 2 を足す式」であると同時に、「3 つの要素を持つリスト」でもある。だから評価器は、自分の入力(プログラム)を、自分の操作対象(リスト)として扱える。
McCarthy はこの設計を、実装するつもりではなく数学の記法として書いていた(らしい)。Steve Russell が論文を読んで「お、これ、実装できるやんけ」と言い出して、最初の Lisp インタプリタが生まれた。eval の存在自体が、Lisp という言語の自己定義になっている。
lispy の自己書き換え
- agent-step が S 式として表現できる (code = data なので)
- その S 式を文字列として LLM に渡せる (
to-sexp
で text 化できる) - LLM が返した文字列を再び S 式として読める (
read-sexp
で tree 化できる) - その S 式を評価器が実行できる (
eval
で binding を更新できる)
四つすべて Lisp の標準装備でできている。McCarthy が 1960 年に置いた基礎の上に、LLM という新しい層を乗せただけ。
そして、これが LLM の登場で初めて意味を持つようになった。それまで、Lisp の自己書き換えは「人間が書き換える」しかなかった。マクロは展開時に行われる静的な書き換えで、走行中の動的な書き換えは、結局ユーザが REPL で打つしかなかった。LLM がそこに入ると、Lisp の自己言及構造が、初めて自律的に動き始める。
安全弁としての init.lispy
ただし、この自己書き換えには上限がある。LLM が誤った S 式を返したり、agent-step を「常に
nilを返す関数」に書き換えたりすると、評価器が壊れる。次の入力に応答できなくなる。
lispy には
init.lispyという seed ファイルがあって、起動時に自動的に load される。中身は agent-step と compose の元の定義。何か壊れたら、REPL で
(load "init.lispy")と打てば元に戻る。
これは、Lisp の伝統そのものでもある。Emacs Lisp も Common Lisp も、走行中に自分を書き換えられるが、起動時にロードされる初期状態を持っている。
llm-callや
dispatch-toolといった単発の操作は、Lisp からは中身が見えない。Lisp が自分の評価規則を書き換えても、これらの基盤は触れない。
つまり、自己書き換えは評価規則のレイヤーでだけ起きる。基盤と seed は保護されている。
まとめ
- agent loop は通常、Python や TypeScript の関数として固められている
- lispy では agent loop を S 式の binding にした
- 走行中に loop を書き換えられる
- LLM に書き換えさせれば、agent が自分の評価規則を自分で更新する
- これは McCarthy が 1960 年に置いた基礎 (Lisp が自分自身を Lisp で書ける) の上に、LLM という新しい層を乗せたもの
Lisp は方言の多い言語族で、それぞれが「S 式 + λ で何をしたいか」の表明として分岐してきた。Common Lisp は工業ドメイン、Scheme は教育と研究、Clojure は並行 Web、Emacs Lisp はエディタ。lispy は LLM agent という新しい生態的ニッチへの一枝、と言いたいところですが、今は凄く中途半端な実装になってるんですよね……。
というか、こんなの誰かがもっといい実装をしていると思うので、知っている方教えてください。
リポジトリ: https://github.com/Flowers-of-Romance/lispy
CC0 で置いてある。